PdfPreviewPanel.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. <template>
  2. <transition name="slide">
  3. <div v-if="visible" class="pdf-panel">
  4. <!-- 头部 -->
  5. <div class="panel-header">
  6. <h3 class="panel-title">{{ fileName || '文件预览' }}</h3>
  7. <div class="header-actions">
  8. <button class="action-btn" @click="zoomOut" :disabled="currentScale <= 1">−</button>
  9. <span class="zoom-text">{{ Math.round(currentScale * 50) }}%</span>
  10. <button class="action-btn" @click="zoomIn" :disabled="currentScale >= 4">+</button>
  11. <button class="close-btn" @click="handleClose">×</button>
  12. </div>
  13. </div>
  14. <!-- 内容区域 -->
  15. <div class="panel-body">
  16. <!-- 加载状态 -->
  17. <div v-if="loading && imageList.length === 0" class="loading-container">
  18. <div class="loading-spinner"></div>
  19. <p>正在加载文件...</p>
  20. </div>
  21. <!-- 错误状态 -->
  22. <div v-else-if="error" class="error-container">
  23. <div class="error-icon">⚠️</div>
  24. <p class="error-message">{{ error }}</p>
  25. <button class="retry-btn" @click="loadPdf">重试</button>
  26. </div>
  27. <!-- PDF预览区域 -->
  28. <div v-show="!error && imageList.length > 0" class="pdf-container" ref="pdfContainer" @scroll="handleScroll">
  29. <div class="pages-wrapper">
  30. <div v-for="(imgUrl, index) in imageList" :key="index" class="page-container">
  31. <img :src="imgUrl" class="pdf-page-img" :alt="`第${index + 1}页`" />
  32. <!-- 水印层 -->
  33. <div class="watermark-layer" v-if="watermarkConfig">
  34. <div class="watermark-grid">
  35. <span v-for="n in 50" :key="n" class="watermark-text">
  36. {{ watermarkFullText }}
  37. </span>
  38. </div>
  39. </div>
  40. </div>
  41. </div>
  42. <!-- 加载更多提示 -->
  43. <div v-if="loading && imageList.length > 0 && imageList.length < totalPages" class="loading-more">
  44. <div class="loading-spinner-small"></div>
  45. <span>加载中 {{ imageList.length }}/{{ totalPages }}</span>
  46. </div>
  47. </div>
  48. </div>
  49. <!-- 底部工具栏 -->
  50. <div class="panel-footer">
  51. <div class="page-nav">
  52. <button class="nav-btn" @click="prevPage" :disabled="currentPage <= 1">‹</button>
  53. <span class="page-info">{{ currentPage }} / {{ totalPages || '-' }}</span>
  54. <button class="nav-btn" @click="nextPage" :disabled="currentPage >= totalPages">›</button>
  55. </div>
  56. </div>
  57. </div>
  58. </transition>
  59. </template>
  60. <script setup>
  61. import { ref, watch, onBeforeUnmount, computed } from 'vue'
  62. import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js'
  63. import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.js?url'
  64. // 设置worker - 使用本地worker
  65. pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
  66. const props = defineProps({
  67. visible: { type: Boolean, default: false },
  68. fileUrl: { type: String, default: '' },
  69. fileName: { type: String, default: '' },
  70. watermarkConfig: { type: Object, default: null }
  71. })
  72. const emit = defineEmits(['close'])
  73. const loading = ref(false)
  74. const error = ref('')
  75. const totalPages = ref(0)
  76. const currentPage = ref(1)
  77. const currentScale = ref(2) // 默认scale=2
  78. const pdfContainer = ref(null)
  79. const imageList = ref([])
  80. let pdfDocument = null
  81. // 计算完整水印文本
  82. const watermarkFullText = computed(() => {
  83. if (!props.watermarkConfig) return ''
  84. const { username, account, date } = props.watermarkConfig
  85. return `${username || ''} ${account || ''} ${date || ''}`.trim()
  86. })
  87. // 加载PDF
  88. const loadPdf = async () => {
  89. if (!props.fileUrl) {
  90. error.value = '文件地址为空'
  91. return
  92. }
  93. loading.value = true
  94. error.value = ''
  95. imageList.value = []
  96. totalPages.value = 0
  97. currentPage.value = 1
  98. try {
  99. // 先获取PDF文件的ArrayBuffer
  100. const response = await fetch(props.fileUrl)
  101. const arrayBuffer = await response.arrayBuffer()
  102. // 使用arrayBuffer加载PDF,配置CMap支持中文字体
  103. const loadingTask = pdfjsLib.getDocument({
  104. data: arrayBuffer,
  105. cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/cmaps/',
  106. cMapPacked: true,
  107. standardFontDataUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/standard_fonts/'
  108. })
  109. pdfDocument = await loadingTask.promise
  110. totalPages.value = pdfDocument.numPages
  111. // 逐页渲染
  112. await renderAllPages()
  113. } catch (err) {
  114. console.error('PDF加载失败:', err)
  115. error.value = '文件加载失败,请稍后重试'
  116. loading.value = false
  117. }
  118. }
  119. // 渲染所有页面
  120. const renderAllPages = async () => {
  121. const tempList = []
  122. for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
  123. const imageUrl = await renderPageToImage(pageNum)
  124. if (imageUrl) {
  125. tempList.push(imageUrl)
  126. imageList.value = [...tempList]
  127. }
  128. if (pageNum === 1) loading.value = false
  129. }
  130. loading.value = false
  131. }
  132. // 渲染单页到图片
  133. const renderPageToImage = async (pageNum) => {
  134. if (!pdfDocument) return null
  135. try {
  136. const page = await pdfDocument.getPage(pageNum)
  137. const viewport = page.getViewport({ scale: currentScale.value })
  138. const canvas = document.createElement('canvas')
  139. canvas.width = Math.floor(viewport.width)
  140. canvas.height = Math.floor(viewport.height)
  141. const context = canvas.getContext('2d', { willReadFrequently: true })
  142. if (!context) return null
  143. // 设置白色背景
  144. context.fillStyle = '#ffffff'
  145. context.fillRect(0, 0, canvas.width, canvas.height)
  146. await page.render({
  147. canvasContext: context,
  148. viewport: viewport
  149. }).promise
  150. return canvas.toDataURL('image/png')
  151. } catch (err) {
  152. console.error(`渲染第${pageNum}页失败:`, err)
  153. return null
  154. }
  155. }
  156. // 滚动监听
  157. const handleScroll = () => {
  158. if (!pdfContainer.value || totalPages.value === 0) return
  159. const container = pdfContainer.value
  160. const containerRect = container.getBoundingClientRect()
  161. const images = container.querySelectorAll('.pdf-page-img')
  162. for (let i = 0; i < images.length; i++) {
  163. const rect = images[i].getBoundingClientRect()
  164. if (rect.top >= containerRect.top - rect.height / 2 && rect.top < containerRect.bottom) {
  165. currentPage.value = i + 1
  166. break
  167. }
  168. }
  169. }
  170. const prevPage = () => {
  171. if (currentPage.value > 1) {
  172. currentPage.value--
  173. scrollToPage(currentPage.value)
  174. }
  175. }
  176. const nextPage = () => {
  177. if (currentPage.value < totalPages.value) {
  178. currentPage.value++
  179. scrollToPage(currentPage.value)
  180. }
  181. }
  182. const scrollToPage = (pageNum) => {
  183. if (!pdfContainer.value) return
  184. const images = pdfContainer.value.querySelectorAll('.pdf-page-img')
  185. if (images[pageNum - 1]) {
  186. images[pageNum - 1].scrollIntoView({ behavior: 'smooth', block: 'start' })
  187. }
  188. }
  189. // 缩放
  190. const zoomIn = async () => {
  191. if (currentScale.value < 4) {
  192. currentScale.value = Math.min(4, currentScale.value + 0.5)
  193. await reRenderAll()
  194. }
  195. }
  196. const zoomOut = async () => {
  197. if (currentScale.value > 1) {
  198. currentScale.value = Math.max(1, currentScale.value - 0.5)
  199. await reRenderAll()
  200. }
  201. }
  202. const reRenderAll = async () => {
  203. if (!pdfDocument) return
  204. loading.value = true
  205. const newList = []
  206. for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
  207. const imageUrl = await renderPageToImage(pageNum)
  208. if (imageUrl) {
  209. newList.push(imageUrl)
  210. }
  211. }
  212. imageList.value = newList
  213. loading.value = false
  214. }
  215. const handleClose = () => emit('close')
  216. watch([() => props.visible, () => props.fileUrl], ([newVisible, newUrl]) => {
  217. if (newVisible && newUrl) {
  218. currentScale.value = 2
  219. loadPdf()
  220. } else if (!newVisible) {
  221. pdfDocument = null
  222. totalPages.value = 0
  223. currentPage.value = 1
  224. imageList.value = []
  225. error.value = ''
  226. }
  227. }, { immediate: true })
  228. onBeforeUnmount(() => { pdfDocument = null })
  229. </script>
  230. <style scoped lang="less">
  231. .slide-enter-active, .slide-leave-active { transition: transform 0.3s ease; }
  232. .slide-enter-from, .slide-leave-to { transform: translateX(100%); }
  233. .pdf-panel {
  234. position: fixed;
  235. top: 0;
  236. right: 0;
  237. width: 40%;
  238. height: 100vh;
  239. background: rgba(255, 255, 255, 0.98);
  240. backdrop-filter: blur(12px);
  241. display: flex;
  242. flex-direction: column;
  243. box-shadow: -4px 0 20px rgba(0, 0, 0, 0.08);
  244. border-left: 1px solid #e5e7eb;
  245. z-index: 1000;
  246. }
  247. .panel-header {
  248. display: flex;
  249. justify-content: space-between;
  250. align-items: center;
  251. padding: 12px 16px;
  252. border-bottom: 1px solid #e5e7eb;
  253. background: rgba(255, 255, 255, 0.9);
  254. .panel-title {
  255. margin: 0;
  256. font-size: 14px;
  257. font-weight: 500;
  258. color: #1f2937;
  259. overflow: hidden;
  260. text-overflow: ellipsis;
  261. white-space: nowrap;
  262. max-width: 50%;
  263. }
  264. .header-actions {
  265. display: flex;
  266. align-items: center;
  267. gap: 4px;
  268. .action-btn {
  269. width: 28px;
  270. height: 28px;
  271. display: flex;
  272. align-items: center;
  273. justify-content: center;
  274. background: #f3f4f6;
  275. border: none;
  276. border-radius: 6px;
  277. cursor: pointer;
  278. font-size: 14px;
  279. color: #6b7280;
  280. transition: all 0.2s;
  281. &:hover:not(:disabled) { background: #e5e7eb; color: #3b82f6; }
  282. &:disabled { opacity: 0.4; cursor: not-allowed; }
  283. }
  284. .zoom-text {
  285. min-width: 40px;
  286. text-align: center;
  287. font-size: 12px;
  288. color: #6b7280;
  289. }
  290. .close-btn {
  291. width: 28px;
  292. height: 28px;
  293. display: flex;
  294. align-items: center;
  295. justify-content: center;
  296. background: transparent;
  297. border: none;
  298. cursor: pointer;
  299. font-size: 18px;
  300. color: #9ca3af;
  301. margin-left: 8px;
  302. &:hover { color: #ef4444; }
  303. }
  304. }
  305. }
  306. .panel-body {
  307. flex: 1;
  308. overflow: hidden;
  309. background: #f8fafc;
  310. position: relative;
  311. }
  312. .loading-container, .error-container {
  313. height: 100%;
  314. display: flex;
  315. flex-direction: column;
  316. align-items: center;
  317. justify-content: center;
  318. padding: 20px;
  319. color: #6b7280;
  320. }
  321. .loading-spinner {
  322. width: 32px;
  323. height: 32px;
  324. border: 3px solid #e5e7eb;
  325. border-top-color: #60a5fa;
  326. border-radius: 50%;
  327. animation: spin 1s linear infinite;
  328. margin-bottom: 12px;
  329. }
  330. .loading-spinner-small {
  331. width: 16px;
  332. height: 16px;
  333. border: 2px solid #e5e7eb;
  334. border-top-color: #60a5fa;
  335. border-radius: 50%;
  336. animation: spin 1s linear infinite;
  337. }
  338. @keyframes spin { to { transform: rotate(360deg); } }
  339. .error-icon { font-size: 32px; margin-bottom: 8px; }
  340. .error-message { color: #f87171; font-size: 13px; margin-bottom: 12px; }
  341. .retry-btn {
  342. padding: 6px 16px;
  343. background: #fff;
  344. color: #3b82f6;
  345. border: 1px solid #bfdbfe;
  346. border-radius: 16px;
  347. cursor: pointer;
  348. font-size: 13px;
  349. &:hover { background: #eff6ff; }
  350. }
  351. .pdf-container {
  352. height: 100%;
  353. overflow: auto;
  354. padding: 12px;
  355. }
  356. .pages-wrapper {
  357. display: flex;
  358. flex-direction: column;
  359. align-items: center;
  360. gap: 12px;
  361. }
  362. .page-container {
  363. position: relative;
  364. background: #fff;
  365. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  366. border-radius: 4px;
  367. }
  368. .pdf-page-img {
  369. display: block;
  370. max-width: 100%;
  371. height: auto;
  372. }
  373. .watermark-layer {
  374. position: absolute;
  375. top: 0;
  376. left: 0;
  377. right: 0;
  378. bottom: 0;
  379. pointer-events: none;
  380. overflow: hidden;
  381. }
  382. .watermark-grid {
  383. position: absolute;
  384. top: -100%;
  385. left: -100%;
  386. width: 300%;
  387. height: 300%;
  388. display: flex;
  389. flex-wrap: wrap;
  390. align-content: flex-start;
  391. transform: rotate(-45deg);
  392. transform-origin: center center;
  393. }
  394. .watermark-text {
  395. color: rgba(100, 100, 100, 0.15);
  396. font-size: 14px;
  397. white-space: nowrap;
  398. user-select: none;
  399. padding: 30px 50px;
  400. flex-shrink: 0;
  401. }
  402. .loading-more {
  403. display: flex;
  404. align-items: center;
  405. justify-content: center;
  406. gap: 8px;
  407. padding: 12px;
  408. color: #6b7280;
  409. font-size: 12px;
  410. }
  411. .panel-footer {
  412. padding: 8px 16px;
  413. border-top: 1px solid #e5e7eb;
  414. background: rgba(255, 255, 255, 0.9);
  415. display: flex;
  416. justify-content: center;
  417. }
  418. .page-nav {
  419. display: flex;
  420. align-items: center;
  421. gap: 8px;
  422. .nav-btn {
  423. width: 28px;
  424. height: 28px;
  425. display: flex;
  426. align-items: center;
  427. justify-content: center;
  428. background: #f3f4f6;
  429. border: none;
  430. border-radius: 6px;
  431. cursor: pointer;
  432. font-size: 14px;
  433. color: #374151;
  434. &:hover:not(:disabled) { background: #e5e7eb; color: #3b82f6; }
  435. &:disabled { opacity: 0.4; cursor: not-allowed; }
  436. }
  437. .page-info {
  438. font-size: 12px;
  439. color: #6b7280;
  440. min-width: 50px;
  441. text-align: center;
  442. }
  443. }
  444. </style>