<template>
|
<view v-if="show" class="pdf-preview-mask">
|
<!-- 顶部栏 -->
|
<view class="pp-header">
|
<text class="pp-btn" @tap="close">关闭</text>
|
<text class="pp-title">{{ title || "文档预览" }}</text>
|
<view class="pp-tools">
|
<text class="pp-zoom-btn" @tap="zoomOut">−</text>
|
<text class="pp-zoom-label">{{ Math.round(zoom * 100) }}%</text>
|
<text class="pp-zoom-btn" @tap="zoomIn">+</text>
|
<text class="pp-page">{{ pageCount ? curPage + "/" + pageCount : "" }}</text>
|
</view>
|
</view>
|
|
<!-- PDF 页面容器(canvas 动态渲染) -->
|
<view
|
ref="container"
|
class="pp-body"
|
:class="{ 'pp-scrollable': zoom > 1 }"
|
></view>
|
|
<!-- 加载 / 错误提示 -->
|
<view v-if="loading" class="pp-tip">PDF 加载中…</view>
|
<view v-if="error" class="pp-tip pp-error">{{ error }}</view>
|
</view>
|
</template>
|
|
<script setup>
|
// 注意:本组件仅在 H5 端使用(小程序端用 uni.openDocument 原生预览),
|
// 父组件需通过 #ifdef H5 引入,避免 pdfjs-dist 被打包进小程序。
|
import { ref, watch, nextTick } from "vue";
|
import * as pdfjsLib from "pdfjs-dist";
|
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.js?url";
|
|
// 使用打包内的 worker 文件,避免额外部署、也避免依赖浏览器原生 PDF 能力
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
const props = defineProps({
|
show: {
|
type: Boolean,
|
default: false,
|
},
|
url: {
|
type: String,
|
default: "",
|
},
|
title: {
|
type: String,
|
default: "",
|
},
|
});
|
|
const emit = defineEmits(["close"]);
|
|
const container = ref(null);
|
const loading = ref(false);
|
const error = ref("");
|
const pageCount = ref(0);
|
const curPage = ref(0);
|
const zoom = ref(1);
|
|
let pdfDoc = null; // 缓存已加载的 PDF,缩放时直接复用,避免重复请求
|
|
const close = () => {
|
emit("close");
|
};
|
|
// 兼容不同环境下 ref 可能返回组件实例或 DOM 元素
|
const getContainerEl = () => container.value?.$el || container.value;
|
|
// 逐页渲染到 canvas,按屏宽 * zoom 缩放
|
const renderPages = async (pdf) => {
|
const el = getContainerEl();
|
if (!el) return;
|
|
el.innerHTML = "";
|
// 去掉容器内边距,让画布按内容区实际宽度铺满,避免右侧被溢出裁剪
|
el.style.padding = "0";
|
|
const width = (el.clientWidth || window.innerWidth || 375) * zoom.value;
|
const dpr = window.devicePixelRatio || 1;
|
|
for (let i = 1; i <= pdf.numPages; i++) {
|
const page = await pdf.getPage(i);
|
const baseViewport = page.getViewport({ scale: 1 });
|
const scale = (width / baseViewport.width) * dpr;
|
const viewport = page.getViewport({ scale });
|
|
const canvas = document.createElement("canvas");
|
canvas.width = viewport.width;
|
canvas.height = viewport.height;
|
canvas.style.width = viewport.width / dpr + "px";
|
canvas.style.height = viewport.height / dpr + "px";
|
canvas.style.display = "block";
|
canvas.style.margin = "0 auto 16px";
|
el.appendChild(canvas);
|
|
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
|
curPage.value = i;
|
}
|
};
|
|
// 加载 PDF
|
const loadPdf = async () => {
|
if (!props.url) return;
|
const el = getContainerEl();
|
if (!el) return;
|
|
loading.value = true;
|
error.value = "";
|
pageCount.value = 0;
|
curPage.value = 0;
|
el.innerHTML = "";
|
|
try {
|
// 静态文件一般无需鉴权头;不带 header 可避免触发 CORS 预检
|
const pdf = await pdfjsLib.getDocument({ url: props.url }).promise;
|
pdfDoc = pdf;
|
pageCount.value = pdf.numPages;
|
await renderPages(pdf);
|
} catch (e) {
|
// pdf.js 因跨域/鉴权等原因失败时,降级为浏览器原生 iframe 预览(导航加载,不受 CORS 限制)
|
console.warn("pdf.js 渲染失败,降级为 iframe 原生预览:", e);
|
renderIframe(el);
|
} finally {
|
loading.value = false;
|
}
|
};
|
|
// 降级方案:用 iframe 走浏览器原生 PDF 预览
|
const renderIframe = (el) => {
|
try {
|
el.style.padding = "0";
|
const iframe = document.createElement("iframe");
|
iframe.src = props.url;
|
iframe.style.width = "100%";
|
iframe.style.height = (el.clientHeight || window.innerHeight || 600) + "px";
|
iframe.style.border = "none";
|
iframe.style.display = "block";
|
iframe.style.background = "#fff";
|
el.appendChild(iframe);
|
} catch (e) {
|
console.error("iframe 预览失败:", e);
|
error.value = "PDF 加载失败,请稍后重试";
|
}
|
};
|
|
// 缩放:修改 zoom 后复用已加载的 pdf 重新渲染
|
const applyZoom = (next) => {
|
zoom.value = Math.min(4, Math.max(0.5, next));
|
if (pdfDoc) renderPages(pdfDoc);
|
};
|
const zoomIn = () => applyZoom(zoom.value + 0.25);
|
const zoomOut = () => applyZoom(zoom.value - 0.25);
|
|
watch(
|
() => props.show,
|
async (val) => {
|
if (val) {
|
zoom.value = 1;
|
await nextTick();
|
loadPdf();
|
}
|
}
|
);
|
</script>
|
|
<style lang="scss" scoped>
|
.pdf-preview-mask {
|
position: fixed;
|
top: 0;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
z-index: 10090;
|
background: rgba(0, 0, 0, 0.7);
|
display: flex;
|
flex-direction: column;
|
|
.pp-header {
|
height: 88rpx;
|
flex-shrink: 0;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 0 30rpx;
|
background: #fff;
|
|
.pp-title {
|
flex: 1;
|
text-align: center;
|
font-size: 30rpx;
|
font-weight: 600;
|
color: #303133;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
margin: 0 20rpx;
|
}
|
|
.pp-btn {
|
font-size: 28rpx;
|
color: #007aff;
|
padding: 10rpx 20rpx;
|
}
|
|
.pp-tools {
|
display: flex;
|
align-items: center;
|
gap: 12rpx;
|
|
.pp-zoom-btn {
|
width: 48rpx;
|
height: 48rpx;
|
line-height: 48rpx;
|
text-align: center;
|
font-size: 36rpx;
|
color: #007aff;
|
background: #f2f6fc;
|
border-radius: 8rpx;
|
}
|
|
.pp-zoom-label {
|
font-size: 22rpx;
|
color: #606266;
|
min-width: 64rpx;
|
text-align: center;
|
}
|
|
.pp-page {
|
font-size: 24rpx;
|
color: #909399;
|
min-width: 80rpx;
|
text-align: right;
|
margin-left: 8rpx;
|
}
|
}
|
}
|
|
.pp-body {
|
flex: 1;
|
overflow-y: auto;
|
overflow-x: hidden;
|
background: #525659;
|
padding: 20rpx;
|
box-sizing: border-box;
|
|
&.pp-scrollable {
|
overflow-x: auto;
|
}
|
}
|
|
.pp-tip {
|
position: absolute;
|
top: 50%;
|
left: 50%;
|
transform: translate(-50%, -50%);
|
color: #fff;
|
font-size: 28rpx;
|
padding: 20rpx 40rpx;
|
background: rgba(0, 0, 0, 0.6);
|
border-radius: 12rpx;
|
z-index: 2;
|
}
|
|
.pp-error {
|
color: #ff4d4f;
|
background: rgba(255, 255, 255, 0.95);
|
}
|
}
|
</style>
|