<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()" <!-- 统一为字符串,避免 el-tabs 类型警告 -->
>
<!-- 单个 PDF 滚动容器(每个 tab 独立 ref,修复类型断言) -->
<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)"
>
<!-- PDF 渲染容器(每个 tab 独立存储,修复类型断言) -->
<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' // 导入 CSS 类型
import * as pdfjsLib from 'pdfjs-dist'
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url'
// 导入 pdfjs 核心类型(解决 PDFLoadingTask/promise 类型错误)
import type {
PDFLoadingTask,
PDFDocumentProxy,
PageViewport,
RenderTask
} from 'pdfjs-dist'
// 导入 Element Plus 组件(确保类型正确)
import { ElButton, ElTabs, ElTabPane } from 'element-plus'
import type { TabsPaneContext } from 'element-plus'
// PDF.js 初始化:设置 Worker 路径
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
}>()
/* ---------------- 状态管理(每个 tab 独立数据) ---------------- */
// 标签页数据:id/名称/PDF 地址
const tabs = ref<Array<{ id: string | number; name: string; url: string }>>([])
// 当前激活的标签页(统一为字符串类型,适配 el-tabs)
const activeTab = ref<string>('')
// 每个 tab 的 PDF 渲染容器(DOM 引用,明确类型)
const pdfContainerMap = new Map<string | number, HTMLDivElement>()
// 每个 tab 的滚动容器(DOM 引用,明确类型)
const scrollRefMap = new Map<string | number, HTMLDivElement>()
// 每个 tab 的缩放比例(0.5~3 范围)
const tabScale = new Map<string | number, number>()
// 每个 tab 的 PDF 原始视口(用于计算坐标,明确类型)
const tabBaseViewport = new Map<string | number, PageViewport>()
// PDF 真实渲染尺寸
const pdfRealSize = ref({ width: 0, height: 0 })
// 弹窗 DOM 引用(明确类型)
const modalRef = ref<HTMLDivElement | null>(null)
// 异步渲染中断控制器(防止切换 tab 时重复渲染)
let abortController: AbortController | null = null
// 当前 PDF 拖拽对应的滚动容器(解决事件参数不匹配问题)
let currentScrollContainer: HTMLDivElement | null = null
/* ---------------- 弹窗位置 & 样式(修复 cursor 类型) ---------------- */
// 显式声明 modalStyle 类型,cursor 支持所有 CSS cursor 值
const modalStyle = reactive<{
left: string
top: string
width: string
height: string
cursor: CSSProperties['cursor'] // 关键:使用 CSS 原生 cursor 类型
}>({
left: '0px',
top: '0px',
width: '800px',
height: '600px',
cursor: 'default' // 初始值符合类型
})
// 拖拽起始坐标
let dragStart: { x: number; y: number } | null = null
/* ---------------- 标签页初始化 & 渲染 ---------------- */
/**
* 打开 PDF 标签页(外部调用入口)
* @param list 标签页数据:code(唯一ID)/name(标签名)/url(PDF地址)
*/
async function openWithTabs(
list: Array<{ code: string; name: string; url: string }>
) {
// 1. 清空历史数据(防止前一次残留导致重复)
tabs.value = []
pdfContainerMap.clear()
scrollRefMap.clear()
tabScale.clear()
tabBaseViewport.clear()
if (abortController) {
abortController.abort()
abortController = null
}
// 2. 格式化标签页数据(过滤 all_files 项)
tabs.value = list
.filter(item => item.name !== 'all_files')
.map(item => ({ id: item.code, name: item.name, url: item.url }))
// 3. 激活第一个标签页(转为字符串,适配 el-tabs)
const firstTabId = tabs.value[0]?.id.toString() ?? ''
activeTab.value = firstTabId
emit('update:visible', true)
// 4. 等待 DOM 更新后,渲染第一个标签页的 PDF
await nextTick()
const firstTab = tabs.value[0]
if (firstTab) {
const container = pdfContainerMap.get(firstTab.id)
if (container) {
await renderSinglePDF(firstTab.url, container, firstTab.id)
}
}
// 5. 弹窗居中显示
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
modalStyle.left = `${(windowWidth - 800) / 2}px`
modalStyle.top = `${(windowHeight - 600) / 2}px`
}
/**
* 绑定 PDF 容器到 Map(明确类型)
* @param tabId 标签页唯一ID
* @param el DOM 元素(已断言为 HTMLDivElement)
*/
function setPdfContainer(tabId: string | number, el: HTMLDivElement) {
pdfContainerMap.set(tabId, el)
}
/**
* 渲染单个标签页的 PDF(核心渲染逻辑,修复 pdfjs 类型)
* @param pdfUrl PDF 文件地址
* @param container 渲染容器(明确 HTMLDivElement 类型)
* @param currentTabId 当前标签页ID(防止异步渲染错位)
*/
async function renderSinglePDF(
pdfUrl: string,
container: HTMLDivElement,
currentTabId: string | number
) {
// 校验:当前标签页是否已切换,若切换则终止渲染
if (activeTab.value !== currentTabId.toString()) return
// 校验:容器是否合法
if (!(container instanceof HTMLDivElement)) return
// 1. 中断前一次未完成的渲染(解决重复加载)
if (abortController) {
abortController.abort()
abortController = null
}
abortController = new AbortController()
// 2. 清空容器(删除所有旧内容,防止残留)
container.innerHTML = ''
// 获取当前标签页的滚动容器
const scrollContainer = scrollRefMap.get(currentTabId)
if (!scrollContainer) return
try {
// 3. 加载 PDF 文件(显式声明 PDFLoadingTask 类型,解决 promise 类型错误)
const pdfLoadingTask: PDFLoadingTask<PDFDocumentProxy> = pdfjsLib.getDocument({
url: pdfUrl,
signal: abortController.signal
})
const pdfDoc = await pdfLoadingTask.promise // 现在 TS 能识别 promise 属性
// 渲染第一页(显式声明 Page 类型)
const pdfPage = await pdfDoc.getPage(1)
// 再次校验标签页(异步延迟防护)
if (activeTab.value !== currentTabId.toString()) return
// 4. 计算渲染尺寸(基于当前标签页的缩放比例)
const baseViewport = pdfPage.getViewport({ scale: 1 })
tabBaseViewport.set(currentTabId, baseViewport)
const currentScale = tabScale.get(currentTabId) ?? 1 // 默认缩放 1
// 基准宽度 800px,适配弹窗初始宽度
const renderScale = currentScale * (800 / baseViewport.width)
const renderViewport = pdfPage.getViewport({ scale: renderScale })
// 5. 创建 Canvas 并渲染 PDF
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:#f56c6c;">Canvas 初始化失败</div>'
return
}
canvas.width = renderViewport.width
canvas.height = renderViewport.height
canvas.dataset.scale = String(currentScale) // 存储当前缩放比例
container.appendChild(canvas)
// 渲染 PDF 内容(显式声明 RenderTask 类型,解决参数类型警告)
const renderTask: RenderTask = pdfPage.render({
canvasContext: ctx,
viewport: renderViewport,
signal: abortController.signal
} as Parameters<typeof pdfPage.render>[0]) // 类型断言匹配渲染参数
await renderTask.promise
// 第三次校验标签页(渲染完成后防护)
if (activeTab.value !== currentTabId.toString()) {
container.innerHTML = ''
return
}
// 6. 设置滚动容器尺寸(适配 PDF 渲染尺寸)
scrollContainer.style.width = `${renderViewport.width}px`
scrollContainer.style.height = `${renderViewport.height}px`
pdfRealSize.value = {
width: renderViewport.width,
height: renderViewport.height
}
// 7. 若有高亮需求,渲染高亮区域
if (props.highlight) {
drawHighlight(props.highlight, currentTabId)
}
} catch (error) {
// 忽略中断错误,其他错误提示
if ((error as Error).name !== 'AbortError') {
console.error(`PDF 加载失败(标签页: ${currentTabId}):`, error)
container.innerHTML = `<div style="padding:20px;text-align:center;color:#f56c6c;">PDF 加载失败,请重试</div>`
}
}
}
/* ---------------- 标签页切换 & 缩放 ---------------- */
/**
* 切换标签页时触发(防止 el-tabs 自带事件与 watch 冲突)
*/
function handleTabClick(tab: TabsPaneContext) {
emit('tabschange', tab.paneName)
}
/**
* 监听 activeTab 变化:切换标签页时重新渲染 PDF
*/
watch(activeTab, (newTabIdStr) => {
// 将字符串 tabId 转回原始类型(适配 Map 存储的 key)
const newTabId = tabs.value.find(tab => tab.id.toString() === newTabIdStr)?.id
if (!newTabId) return
const targetTab = tabs.value.find(tab => tab.id === newTabId)
const targetContainer = pdfContainerMap.get(newTabId)
if (targetTab && targetContainer) {
renderSinglePDF(targetTab.url, targetContainer, newTabId)
}
}, { immediate: false })
/**
* 放大 PDF(当前激活标签页)
*/
const zoomIn = () => {
const currentTabIdStr = activeTab.value
const currentTabId = tabs.value.find(tab => tab.id.toString() === currentTabIdStr)?.id
if (!currentTabId) return
// 缩放范围:0.5 ~ 3
const newScale = Math.min((tabScale.get(currentTabId) ?? 1) + 0.2, 3)
tabScale.set(currentTabId, newScale)
rerenderActiveTab()
}
/**
* 缩小 PDF(当前激活标签页)
*/
const zoomOut = () => {
const currentTabIdStr = activeTab.value
const currentTabId = tabs.value.find(tab => tab.id.toString() === currentTabIdStr)?.id
if (!currentTabId) return
// 缩放范围:0.5 ~ 3
const newScale = Math.max((tabScale.get(currentTabId) ?? 1) - 0.2, 0.5)
tabScale.set(currentTabId, newScale)
rerenderActiveTab()
}
/**
* 重新渲染当前激活的标签页
*/
async function rerenderActiveTab() {
const currentTabIdStr = activeTab.value
const currentTabId = tabs.value.find(tab => tab.id.toString() === currentTabIdStr)?.id
if (!currentTabId) return
const targetTab = tabs.value.find(tab => tab.id === currentTabId)
const targetContainer = pdfContainerMap.get(currentTabId)
if (targetTab && targetContainer) {
await renderSinglePDF(targetTab.url, targetContainer, currentTabId)
}
}
/* ---------------- 弹窗拖拽功能 ---------------- */
/**
* 开始拖拽弹窗(仅头部和遮罩区域触发)
*/
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' // 现在类型兼容(CSSProperties['cursor'] 支持)
// 绑定全局拖拽/停止事件(使用同一函数引用)
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 // 是否处于 PDF 拖拽状态
let pdfDragStart = { x: 0, y: 0 } // PDF 拖拽起始坐标
let scrollStart = { left: 0, top: 0 } // 滚动容器起始位置
/**
* PDF 内容鼠标按下:开始拖拽(仅放大时支持,存储当前滚动容器)
*/
function onPdfMouseDown(e: MouseEvent, tabId: string | number) {
const scrollContainer = scrollRefMap.get(tabId)
if (!scrollContainer) return
// 仅当缩放比例 >1 时允许拖拽
const currentScale = tabScale.get(tabId) ?? 1
if (currentScale <= 1) return
isPdfDragging = true
pdfDragStart = { x: e.clientX, y: e.clientY }
scrollStart = {
left: scrollContainer.scrollLeft,
top: scrollContainer.scrollTop
}
// 存储当前拖拽对应的滚动容器(解决事件参数不匹配)
currentScrollContainer = scrollContainer
// 绑定全局拖拽/停止事件(使用无额外参数的函数)
document.addEventListener('mousemove', onPdfMouseMove)
document.addEventListener('mouseup', onPdfMouseUp)
e.preventDefault()
}
/**
* PDF 内容拖拽中:更新滚动位置(不依赖外部参数,从状态获取容器)
*/
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' // 即时滚动,无动画
})
}
/**
* PDF 内容拖拽停止:清理事件和状态
*/
function onPdfMouseUp() {
isPdfDragging = false
currentScrollContainer = null // 清空当前滚动容器
// 解绑全局事件(与绑定的函数一致)
document.removeEventListener('mousemove', onPdfMouseMove)
document.removeEventListener('mouseup', onPdfMouseUp)
}
/* ---------------- 滚轮事件(防止冒泡影响页面) ---------------- */
function onWheel(e: WheelEvent, tabId: string | number) {
e.stopPropagation() // 阻止滚轮事件冒泡到页面
const scrollContainer = scrollRefMap.get(tabId)
if (scrollContainer) {
// 自定义滚轮行为(横向+纵向滚动)
scrollContainer.scrollLeft -= e.deltaX
scrollContainer.scrollTop -= e.deltaY
e.preventDefault() // 防止页面整体滚动
}
}
/* ---------------- 高亮区域绘制 ---------------- */
/**
* 绘制 PDF 高亮区域(支持外部调用)
* @param rect 高亮坐标(PDF 原始坐标)
* @param tabId 标签页ID(默认当前激活标签页)
*/
function drawHighlight(
rect: { x0: number; y0: number; x1: number; y1: number },
tabId?: string | number
) {
const targetTabId = tabId ?? (
tabs.value.find(tab => tab.id.toString() === activeTab.value)?.id ?? ''
)
const pdfContainer = pdfContainerMap.get(targetTabId)
const scrollContainer = scrollRefMap.get(targetTabId)
if (!pdfContainer || !scrollContainer) return
// 1. 清除旧的高亮区域
pdfContainer.querySelectorAll('.pdf-highlight').forEach(el => el.remove())
// 2. 获取 Canvas 和基础数据
const canvas = pdfContainer.querySelector('canvas')
const baseViewport = tabBaseViewport.get(targetTabId)
if (!canvas || !baseViewport) return
const currentScale = parseFloat(canvas.dataset.scale || '1')
// 计算 PDF 原始坐标到 Canvas 渲染坐标的比例
const scaleX = canvas.width / baseViewport.width
const scaleY = canvas.height / baseViewport.height
// 3. 计算高亮区域在 Canvas 上的位置和尺寸
const highlightStyle = {
left: `${rect.x0 * scaleX}px`,
top: `${rect.y0 * scaleY}px`,
width: `${(rect.x1 - rect.x0) * scaleX}px`,
height: `${(rect.y1 - rect.y0) * scaleY}px`
}
// 4. 创建高亮元素
const highlightEl = document.createElement('div')
highlightEl.className = 'pdf-highlight'
Object.assign(highlightEl.style, {
...highlightStyle,
position: 'absolute',
background: 'rgba(0, 255, 0, 0.4)',
border: '2px dashed #ff4d4f',
pointerEvents: 'none', // 不影响鼠标事件
zIndex: 9999 // 确保高亮在最上层
})
// 5. 添加到容器并自动滚动到高亮区域
pdfContainer.style.position = 'relative'
pdfContainer.appendChild(highlightEl)
// 等待 DOM 更新后执行滚动
nextTick(() => {
const containerWidth = scrollContainer.clientWidth
const containerHeight = scrollContainer.clientHeight
// 滚动到高亮区域中心
scrollContainer.scrollTo({
left: Math.max(0, parseFloat(highlightStyle.left) + parseFloat(highlightStyle.width) / 2 - containerWidth / 2),
top: Math.max(0, parseFloat(highlightStyle.top) + parseFloat(highlightStyle.height) / 2 - containerHeight / 2),
behavior: 'smooth' // 平滑滚动
})
})
}
/**
* 外部调用的高亮方法(适配暴露接口)
*/
function drawHighlightByCoords(rect: { x0: number; y0: number; x1: number; y1: number }) {
drawHighlight(rect)
}
/* ---------------- 监听 Props 变化 ---------------- */
// 监听 visible 变化:弹窗显示/隐藏时的处理
watch(
() => props.visible,
async (isVisible, oldVisible) => {
// 弹窗隐藏时:重置缩放和中断渲染
if (!isVisible) {
tabScale.clear()
if (abortController) {
abortController.abort()
abortController = null
}
currentScrollContainer = null // 清空拖拽容器状态
return
}
// 弹窗从隐藏变为显示时:居中弹窗
await nextTick()
if (oldVisible === false) {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
modalStyle.left = `${(windowWidth - 800) / 2}px`
modalStyle.top = `${(windowHeight - 600) / 2}px`
}
},
{ immediate: true }
)
// 监听 highlight 变化:外部传入高亮坐标时绘制
watch(
() => props.highlight,
(newHighlight) => {
if (newHighlight && activeTab.value) {
drawHighlight(newHighlight)
}
},
{ immediate: false, deep: true }
)
/* ---------------- 关闭弹窗 ---------------- */
function close() {
// 中断渲染并重置状态
if (abortController) {
abortController.abort()
abortController = null
}
currentScrollContainer = null
pdfRealSize.value = { width: 0, height: 0 }
emit('update:visible', false)
}
/* ---------------- 组件卸载清理 ---------------- */
onUnmounted(() => {
// 1. 中断所有未完成的渲染
if (abortController) {
abortController.abort()
abortController = null
}
// 2. 解绑所有全局事件(防止内存泄漏,确保与绑定函数一致)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onPdfMouseMove)
document.removeEventListener('mouseup', onPdfMouseUp)
// 3. 清空所有状态
pdfContainerMap.clear()
scrollRefMap.clear()
tabScale.clear()
tabBaseViewport.clear()
currentScrollContainer = null
})
/* ---------------- 暴露外部调用的方法 ---------------- */
defineExpose({
openWithTabs, // 打开 PDF 标签页
drawHighlight: drawHighlightByCoords // 绘制高亮区域
})
</script>
<style scoped>
/* 遮罩层:全屏覆盖 */
.pdf-mask {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none; /* 仅弹窗区域可点击 */
background: rgba(0, 0, 0, 0.3); /* 增加半透明背景,提升体验 */
}
/* 弹窗容器:可拖拽、可缩放 */
.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 内容容器:滚动区域 */
.pdf-body {
flex: 1;
overflow: auto;
position: relative; /* 为高亮区域提供定位上下文 */
}
/* PDF Canvas:禁止鼠标事件(避免影响拖拽) */
.pdf-body canvas {
pointer-events: none;
display: block; /* 消除 Canvas 默认空隙 */
}
/* 高亮区域样式 */
.pdf-highlight {
box-sizing: border-box; /* 边框不影响尺寸计算 */
}
/* 适配 Element Plus 标签页样式(避免冲突) */
.pdf-tabs .el-tabs__content {
flex: 1;
overflow: hidden;
padding: 0 !important; /* 清除默认内边距 */
}
.pdf-tabs .el-tab-pane {
height: 100%;
display: flex;
flex-direction: column;
}
</style>这个代码里面有个别是错误的,比如没有PDFLoadingTask,需要帮我重新分析下代码,功能逻辑,把错误的更新掉,并给我重新生成代码
最新发布