WXL
5 天以前 2140c5aae2032673cef7055df9738b9bacf37816
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
<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>