<template>
<Teleport to="body">
<div v-if="visible" class="pdf-mask" @mousedown="startDrag">
<div ref="modalRef" class="pdf-modal" :style="modalStyle">
<!-- PDF 头部:标题 + 操作按钮 -->
<div class="pdf-header">
<span class="title">报关资料</span>
<div class="tools">
<el-button size="small" @click="zoomIn">放大</el-button>
<el-button size="small" @click="zoomOut">缩小</el-button>
<el-button size="small" @click="close">关闭</el-button>
</div>
</div>
<!-- PDF 标签页 + 内容区域 -->
<el-tabs v-model="activeTab" class="pdf-tabs" @tab-click="handleTabClick">
<el-tab-pane v-for="tab in tabs" :key="tab.id" :label="tab.name" :name="tab.id.toString()">
<div class="pdf-body" :ref="(el) => el && scrollRefMap.set(tab.id, el as HTMLDivElement)"
@wheel="(e) => onWheel(e, tab.id)" @mousedown="(e) => onPdfMouseDown(e, tab.id)">
<div :ref="(el) => el && setPdfContainer(tab.id, el as HTMLDivElement)" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick, onUnmounted } from 'vue'
import type { CSSProperties } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url'
import type {
PDFDocumentLoadingTask,
PDFDocumentProxy,
PDFPageProxy,
RenderParameters
} from 'pdfjs-dist/types/src/display/api'
import type { PageViewport } from 'pdfjs-dist'
import { ElButton, ElTabs, ElTabPane } from 'element-plus'
import type { TabsPaneContext } from 'element-plus'
// PDF.js 初始化
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
/* ---------------- Props & Emits ---------------- */
const props = defineProps<{
visible: boolean
url?: string
highlight?: { x0: number; y0: number; x1: number; y1: number } | null
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'tabschange', tabId: string | number): void
}>()
/* ---------------- 状态 ---------------- */
const tabs = ref<Array<{ id: string | number; name: string; url: string }>>([])
const activeTab = ref<string>('')
const pdfContainerMap = new Map<string | number, HTMLDivElement>()
const scrollRefMap = new Map<string | number, HTMLDivElement>()
const tabScale = new Map<string | number, number>()
const tabBaseViewport = new Map<string | number, PageViewport>()
const modalRef = ref<HTMLDivElement | null>(null)
let abortController: AbortController | null = null
let currentScrollContainer: HTMLDivElement | null = null
/* ---------------- 弹窗位置 ---------------- */
const modalStyle = reactive<{
left: string
top: string
width: string
height: string
cursor: CSSProperties['cursor']
}>({
left: '0px',
top: '0px',
width: '800px',
height: '600px',
cursor: 'default'
})
let dragStart: { x: number; y: number } | null = null
/* ---------------- 标签页初始化 ---------------- */
async function openWithTabs(
list: Array<{ code: string; name: string; url: string }>
) {
tabs.value = []
pdfContainerMap.clear()
scrollRefMap.clear()
tabScale.clear()
tabBaseViewport.clear()
clearHighlight();
if (abortController) {
abortController.abort()
abortController = null
}
tabs.value = list
.filter(item => item.name !== 'all_files')
.map(item => ({ id: item.code, name: item.name, url: item.url }))
const firstTabId = tabs.value[0]?.id.toString() ?? ''
activeTab.value = firstTabId
emit('update:visible', true)
await nextTick()
const firstTab = tabs.value[0]
if (firstTab) {
const container = pdfContainerMap.get(firstTab.id)
if (container) await renderSinglePDF(firstTab.url, container, firstTab.id)
}
// 居中
const { innerWidth, innerHeight } = window
modalStyle.left = `${(innerWidth - 800) / 2}px`
modalStyle.top = `${(innerHeight - 600) / 2}px`
}
function setPdfContainer(tabId: string | number, el: HTMLDivElement) {
pdfContainerMap.set(tabId, el)
}
/* ---------------- PDF 渲染 ---------------- */
async function renderSinglePDF(
pdfUrl: string,
container: HTMLDivElement,
currentTabId: string | number
) {
container.querySelectorAll('.pdf-highlight').forEach(el => el.remove())
if (activeTab.value !== currentTabId.toString()) return
if (!(container instanceof HTMLDivElement)) return
container.querySelectorAll('.pdf-highlight').forEach(el => el.remove())
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
container.innerHTML = ''
const scrollContainer = scrollRefMap.get(currentTabId)
if (!scrollContainer) return
try {
const pdfLoadingTask: PDFDocumentLoadingTask = pdfjsLib.getDocument({
url: pdfUrl,
signal: abortController.signal
} as any) as PDFDocumentLoadingTask
const pdfDoc: PDFDocumentProxy = await pdfLoadingTask.promise
const pdfPage: PDFPageProxy = await pdfDoc.getPage(1)
if (activeTab.value !== currentTabId.toString()) return
const baseViewport = pdfPage.getViewport({ scale: 1 })
tabBaseViewport.set(currentTabId, baseViewport)
const currentScale = tabScale.get(currentTabId) ?? 1
const renderScale = currentScale * (800 / baseViewport.width)
const renderViewport = pdfPage.getViewport({ scale: renderScale })
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
container.innerHTML = '<div class="pdf-error">Canvas 初始化失败</div>'
return
}
canvas.width = renderViewport.width
canvas.height = renderViewport.height
canvas.dataset.scale = String(currentScale)
container.appendChild(canvas)
const renderParams: RenderParameters = {
canvasContext: ctx,
viewport: renderViewport,
signal: abortController
} as any
await pdfPage.render(renderParams).promise
if (activeTab.value !== currentTabId.toString()) {
container.innerHTML = ''
return
}
// 关键修改:不再给滚动容器写死宽高
// scrollContainer.style.width = `${renderViewport.width}px`
// scrollContainer.style.height = `${renderViewport.height}px`
if (props.highlight) drawHighlight(props.highlight, currentTabId)
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('PDF 加载失败:', error)
container.innerHTML = '<div class="pdf-error">PDF 加载失败,请重试</div>'
}
}
}
/* ---------------- 标签页切换 & 缩放 ---------------- */
function handleTabClick(tab: TabsPaneContext) {
clearHighlight();
emit('tabschange', tab.paneName)
}
watch(activeTab, (newTabIdStr) => {
clearHighlight();
const tab = tabs.value.find(t => t.id.toString() === newTabIdStr)
if (!tab) return
const container = pdfContainerMap.get(tab.id)
if (container) renderSinglePDF(tab.url, container, tab.id)
})
const zoomIn = () => {
const id = tabs.value.find(t => t.id.toString() === activeTab.value)?.id
if (!id) return
tabScale.set(id, Math.min((tabScale.get(id) ?? 1) + 0.2, 3))
rerenderActiveTab()
}
const zoomOut = () => {
const id = tabs.value.find(t => t.id.toString() === activeTab.value)?.id
if (!id) return
tabScale.set(id, Math.max((tabScale.get(id) ?? 1) - 0.2, 0.5))
rerenderActiveTab()
}
async function rerenderActiveTab() {
const tab = tabs.value.find(t => t.id.toString() === activeTab.value)
if (!tab) return
const container = pdfContainerMap.get(tab.id)
if (container) await renderSinglePDF(tab.url, container, tab.id)
}
/* ---------------- 弹窗拖拽 ---------------- */
function startDrag(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.classList.contains('pdf-mask') && !target.closest('.pdf-header')) return
if (!modalRef.value) return
dragStart = { x: e.clientX, y: e.clientY }
modalStyle.cursor = 'grabbing'
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
e.preventDefault()
}
function onDrag(e: MouseEvent) {
if (!dragStart) return
const dx = e.clientX - dragStart.x
const dy = e.clientY - dragStart.y
modalStyle.left = `${parseFloat(modalStyle.left) + dx}px`
modalStyle.top = `${parseFloat(modalStyle.top) + dy}px`
dragStart = { x: e.clientX, y: e.clientY }
}
function stopDrag() {
dragStart = null
modalStyle.cursor = 'default'
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
/* ---------------- PDF 内容拖拽 ---------------- */
let isPdfDragging = false
let pdfDragStart = { x: 0, y: 0 }
let scrollStart = { left: 0, top: 0 }
function onPdfMouseDown(e: MouseEvent, tabId: string | number) {
const sc = scrollRefMap.get(tabId)
if (!sc) return
const scale = tabScale.get(tabId) ?? 1
if (scale <= 1) return
isPdfDragging = true
pdfDragStart = { x: e.clientX, y: e.clientY }
scrollStart = { left: sc.scrollLeft, top: sc.scrollTop }
currentScrollContainer = sc
document.addEventListener('mousemove', onPdfMouseMove)
document.addEventListener('mouseup', onPdfMouseUp)
e.preventDefault()
}
function onPdfMouseMove(e: MouseEvent) {
if (!isPdfDragging || !currentScrollContainer) return
const dx = e.clientX - pdfDragStart.x
const dy = e.clientY - pdfDragStart.y
currentScrollContainer.scrollTo({
left: scrollStart.left - dx,
top: scrollStart.top - dy,
behavior: 'auto'
})
}
function onPdfMouseUp() {
isPdfDragging = false
currentScrollContainer = null
document.removeEventListener('mousemove', onPdfMouseMove)
document.removeEventListener('mouseup', onPdfMouseUp)
}
/* ---------------- 滚轮事件 ---------------- */
function onWheel(e: WheelEvent, tabId: string | number) {
e.stopPropagation()
const sc = scrollRefMap.get(tabId)
if (sc) {
const scale = tabScale.get(tabId) ?? 1
sc.scrollLeft -= e.deltaX * scale
sc.scrollTop -= e.deltaY * scale
e.preventDefault()
}
}
/* ---------------- 高亮区域绘制 ---------------- */
function drawHighlight(
rect: { x0: number; y0: number; x1: number; y1: number },
tabId?: string | number
) {
const id = tabId ?? tabs.value.find(t => t.id.toString() === activeTab.value)?.id
if (!id) return
const container = pdfContainerMap.get(id)
const sc = scrollRefMap.get(id)
if (!container || !sc) return
container.querySelectorAll('.pdf-highlight').forEach(el => el.remove())
const canvas = container.querySelector('canvas')
const baseViewport = tabBaseViewport.get(id)
if (!canvas || !baseViewport) return
const scale = parseFloat(canvas.dataset.scale || '1')
const scaleX = canvas.width / baseViewport.width
const scaleY = canvas.height / baseViewport.height
const highlightEl = document.createElement('div')
highlightEl.className = 'pdf-highlight'
Object.assign(highlightEl.style, {
position: 'absolute',
left: `${rect.x0 * scaleX}px`,
top: `${rect.y0 * scaleY}px`,
width: `${(rect.x1 - rect.x0) * scaleX}px`,
height: `${(rect.y1 - rect.y0) * scaleY}px`,
background: 'rgba(0, 255, 0, 0.4)',
border: '2px dashed #ff4d4f',
pointerEvents: 'none',
zIndex: 9999
})
container.style.position = 'relative'
container.appendChild(highlightEl)
nextTick(() => {
const cw = sc.clientWidth
const ch = sc.clientHeight
sc.scrollTo({
left: Math.max(0, parseFloat(highlightEl.style.left) - cw / 3),
top: Math.max(0, parseFloat(highlightEl.style.top) - ch / 3),
behavior: 'smooth'
})
})
}
function drawHighlightByCoords(rect: { x0: number; y0: number; x1: number; y1: number }) {
drawHighlight(rect)
}
// 清空所有高亮元素
function clearHighlight() {
// 直接查询 .pdf-mask 下所有高亮元素,确保覆盖所有 Tab 和容器
const allHighlights = document.querySelectorAll('.pdf-mask .pdf-highlight');
allHighlights.forEach(el => el.remove());
}
/* ---------------- 监听 Props ---------------- */
watch(
() => props.visible,
async (v) => {
if (!v) {
tabScale.clear()
if (abortController) {
abortController.abort()
abortController = null
}
currentScrollContainer = null
clearHighlight();
return
}
clearHighlight();
await nextTick()
const { innerWidth, innerHeight } = window
modalStyle.left = `${(innerWidth - 800) / 2}px`
modalStyle.top = `${(innerHeight - 600) / 2}px`
},
{ immediate: true }
)
watch(
() => props.highlight,
(hl) => {
if (hl) {
drawHighlight(hl)
} else {
clearHighlight() // 关键:当 highlight 为 null 时清除高亮
}
},
{ immediate: false, deep: true }
)
/* ---------------- 关闭 ---------------- */
function close() {
if (abortController) {
abortController.abort()
abortController = null
}
currentScrollContainer = null
clearHighlight();
emit('update:visible', false)
}
/* ---------------- 清理 ---------------- */
onUnmounted(() => {
if (abortController) abortController.abort()
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onPdfMouseMove)
document.removeEventListener('mouseup', onPdfMouseUp)
pdfContainerMap.clear()
scrollRefMap.clear()
tabScale.clear()
tabBaseViewport.clear()
currentScrollContainer = null
})
/* ---------------- 暴露 ---------------- */
defineExpose({ openWithTabs, drawHighlight: drawHighlightByCoords })
</script>
<style scoped>
.pdf-mask {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.pdf-modal {
position: absolute;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
pointer-events: auto;
min-width: 400px;
min-height: 300px;
resize: both;
overflow: hidden;
}
.pdf-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f5f7fa;
cursor: grab;
border-bottom: 1px solid #e4e7ed;
user-select: none;
}
.pdf-header:active {
cursor: grabbing;
}
.pdf-header .title {
font-size: 16px;
font-weight: 500;
color: #1f2937;
}
.pdf-header .tools {
display: flex;
gap: 8px;
}
.pdf-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pdf-body {
flex: 1;
overflow: auto;
position: relative;
}
.pdf-body canvas {
pointer-events: none;
display: block;
}
.pdf-highlight {
box-sizing: border-box;
}
.pdf-tabs .el-tabs__content {
flex: 1;
overflow: hidden;
padding: 0 !important;
}
.pdf-tabs .el-tab-pane {
height: 100%;
display: flex;
flex-direction: column;
}
.pdf-error {
padding: 20px;
text-align: center;
color: #f56c6c;
}
</style>
我期望这个弹框组件打开的弹框,能离开当前的浏览器
最新发布