FilePreview.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. <template>
  2. <el-dialog
  3. v-model="visible"
  4. :title="dialogTitle"
  5. :width="dialogWidth"
  6. :fullscreen="isFullscreen"
  7. :class="getDialogClass()"
  8. append-to-body
  9. height="100vh"
  10. top="0vh"
  11. :destroy-on-close="true"
  12. :close-on-click-modal="false"
  13. :close-on-press-escape="true"
  14. @close="handleClose"
  15. >
  16. <template #header="{ titleId, titleClass }">
  17. <div class="preview-header">
  18. <div class="preview-title-section">
  19. <h4 :id="titleId" :class="titleClass">
  20. <el-icon class="title-icon">
  21. <component :is="getFileIcon(fileInfo.type)" />
  22. </el-icon>
  23. {{ fileInfo.name }}
  24. </h4>
  25. <span class="file-size-info">{{ fileInfo.size }}</span>
  26. </div>
  27. <div class="preview-controls">
  28. <el-tooltip :content="isFullscreen ? '退出全屏' : '全屏显示'" placement="bottom">
  29. <el-button
  30. size="small"
  31. :icon="isFullscreen ? Minus : FullScreen"
  32. @click="toggleFullscreen"
  33. circle
  34. type="primary"
  35. />
  36. </el-tooltip>
  37. <el-tooltip content="刷新预览" placement="bottom">
  38. <el-button
  39. size="small"
  40. :icon="RefreshRight"
  41. @click="refreshPreview"
  42. circle
  43. />
  44. </el-tooltip>
  45. </div>
  46. </div>
  47. </template>
  48. <div class="preview-container" v-loading="loading" :element-loading-text="getLoadingText()">
  49. <!-- PDF预览 -->
  50. <div
  51. class="preview-content pdf-content"
  52. v-if="!loading && !error && pdfBlobUrl"
  53. v-show="pdfLoaded"
  54. >
  55. <div class="pdf-embed-container">
  56. <transition name="pdf-fade" mode="out-in">
  57. <div :key="currentPage" class="pdf-page-wrapper">
  58. <VuePdfEmbed
  59. :source="pdfBlobUrl"
  60. :page="currentPage"
  61. :width="Math.floor(600 * scale)"
  62. class="pdf-embed"
  63. @loaded="handlePdfLoaded"
  64. @loading-failed="handlePdfError"
  65. @progress="handlePdfProgress"
  66. @rendered="handlePageRendered"
  67. />
  68. </div>
  69. </transition>
  70. <!-- PDF控制栏 - 集成所有按钮 -->
  71. <div class="pdf-controls" v-if="totalPages > 0">
  72. <!-- 翻页控制 -->
  73. <el-button-group>
  74. <el-button
  75. size="small"
  76. :disabled="currentPage <= 1"
  77. @click="goToPage(currentPage - 1)"
  78. :icon="ArrowLeft"
  79. >
  80. 上一页
  81. </el-button>
  82. <el-input
  83. v-model.number="pageInput"
  84. size="small"
  85. class="page-input"
  86. @keyup.enter="goToInputPage"
  87. @blur="goToInputPage"
  88. />
  89. <span class="page-separator">/</span>
  90. <span class="total-pages">{{ totalPages }}</span>
  91. <el-button
  92. size="small"
  93. :disabled="currentPage >= totalPages"
  94. @click="goToPage(currentPage + 1)"
  95. :icon="ArrowRight"
  96. >
  97. 下一页
  98. </el-button>
  99. </el-button-group>
  100. <!-- 缩放控制 -->
  101. <el-button-group class="zoom-controls">
  102. <el-button
  103. size="small"
  104. @click="zoomOut"
  105. :disabled="scale <= 0.5"
  106. :icon="ZoomOut"
  107. >
  108. 缩小
  109. </el-button>
  110. <span class="zoom-display">{{ Math.round(scale * 100) }}%</span>
  111. <el-button
  112. size="small"
  113. @click="zoomIn"
  114. :disabled="scale >= 3"
  115. :icon="ZoomIn"
  116. >
  117. 放大
  118. </el-button>
  119. <el-button
  120. size="small"
  121. @click="resetZoom"
  122. :icon="RefreshRight"
  123. >
  124. 重置
  125. </el-button>
  126. </el-button-group>
  127. <!-- 操作按钮 -->
  128. <el-button-group class="action-controls">
  129. <el-button
  130. size="small"
  131. :icon="Download"
  132. @click="downloadFile"
  133. :disabled="!pdfBlobUrl"
  134. >
  135. 下载
  136. </el-button>
  137. <el-button
  138. size="small"
  139. :icon="Share"
  140. @click="openPdfInNewWindow"
  141. :disabled="!pdfBlobUrl"
  142. >
  143. 浏览器打开
  144. </el-button>
  145. <el-button
  146. size="small"
  147. @click="handleClose"
  148. >
  149. 关闭
  150. </el-button>
  151. </el-button-group>
  152. </div>
  153. </div>
  154. </div>
  155. <!-- 预览错误状态 -->
  156. <div v-else-if="error || (!loading && !pdfBlobUrl)" class="preview-error">
  157. <el-icon size="64" color="#f56c6c">
  158. <WarningFilled />
  159. </el-icon>
  160. <p class="error-title">无法预览此PDF文档</p>
  161. <p class="error-tip">可能的原因:</p>
  162. <ul class="error-reasons">
  163. <li>网络连接问题</li>
  164. <li>文件获取失败</li>
  165. <li>PDF文件损坏或格式不正确</li>
  166. <li>服务器返回错误</li>
  167. <li>PDF文件过大</li>
  168. <li>PDF文件受密码保护</li>
  169. </ul>
  170. <div class="error-actions">
  171. <el-button type="primary" @click="openPdfInNewWindow">
  172. 浏览器中打开
  173. </el-button>
  174. <el-button @click="refreshPreview">重试</el-button>
  175. </div>
  176. </div>
  177. </div>
  178. </el-dialog>
  179. </template>
  180. <script setup>
  181. import {
  182. Download,
  183. FullScreen,
  184. Minus,
  185. WarningFilled,
  186. RefreshRight,
  187. Share,
  188. Document,
  189. ArrowLeft,
  190. ArrowRight,
  191. ZoomIn,
  192. ZoomOut
  193. } from '@element-plus/icons-vue'
  194. import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
  195. import { ElMessage } from 'element-plus'
  196. import VuePdfEmbed from 'vue-pdf-embed'
  197. import 'vue-pdf-embed/dist/styles/annotationLayer.css'
  198. import 'vue-pdf-embed/dist/styles/textLayer.css'
  199. import { previewPrepareReport } from '@/api/laboratory/functional/report'
  200. import { PipeTaskOrderApi } from '@/api/pressure2/pipetaskorder'
  201. const props = defineProps({
  202. modelValue: {
  203. type: Boolean,
  204. default: false
  205. },
  206. id: {
  207. type: String,
  208. required: true
  209. },
  210. isEditOtherFileFlag: {
  211. type: Boolean,
  212. default: false
  213. },
  214. filename: {
  215. type: String,
  216. default: ''
  217. },
  218. title: {
  219. type: String,
  220. default: '文件预览'
  221. }
  222. })
  223. const emit = defineEmits(['update:modelValue', 'close'])
  224. // 响应式数据
  225. const visible = ref(false)
  226. const loading = ref(false)
  227. const error = ref(false)
  228. const isFullscreen = ref(false)
  229. const pdfBlobUrl = ref('')
  230. const actualFileSize = ref(0)
  231. const pdfLoaded = ref(false) // 新增:标记PDF是否已加载完成
  232. // PDF 相关状态
  233. const currentPage = ref(1)
  234. const totalPages = ref(0)
  235. const scale = ref(2.0)
  236. const pageInput = ref(1)
  237. const isPageChanging = ref(false) // 新增:标记页面切换状态
  238. // 文件信息
  239. const fileInfo = computed(() => {
  240. const filename = props.filename || 'report.pdf'
  241. const filesize = formatFileSize(actualFileSize.value)
  242. return {
  243. name: filename,
  244. size: filesize || '计算中...',
  245. type: 'application/pdf',
  246. id: props.id
  247. }
  248. })
  249. // 计算属性
  250. const dialogTitle = computed(() => {
  251. return `PDF预览${fileInfo.value.name ? ' - ' + fileInfo.value.name : ''}`
  252. })
  253. const dialogWidth = computed(() => {
  254. if (isFullscreen.value) return '100%'
  255. return window.innerWidth > 1400 ? '85%' : window.innerWidth > 1200 ? '90%' : '95%'
  256. })
  257. // 获取文件图标
  258. const getFileIcon = (type) => {
  259. return Document
  260. }
  261. // 获取对话框样式类
  262. const getDialogClass = () => {
  263. return 'pdf-preview-dialog'
  264. }
  265. // 获取加载文本
  266. const getLoadingText = () => {
  267. return '正在加载PDF文档...'
  268. }
  269. // 工具函数
  270. const formatFileSize = (size) => {
  271. if (!size || size === 0) return ''
  272. const bytes = Number(size)
  273. if (isNaN(bytes) || bytes === 0) return ''
  274. const k = 1024
  275. const sizes = ['B', 'KB', 'MB', 'GB']
  276. const i = Math.floor(Math.log(bytes) / Math.log(k))
  277. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  278. }
  279. // 优化后的加载预览函数
  280. const loadPreview = async () => {
  281. if (!props.id) {
  282. error.value = true
  283. return
  284. }
  285. // 只在真正需要重新加载时才清理blob URL
  286. const shouldReload = !pdfBlobUrl.value
  287. if (shouldReload && pdfBlobUrl.value) {
  288. URL.revokeObjectURL(pdfBlobUrl.value)
  289. pdfBlobUrl.value = ''
  290. }
  291. // 重置状态
  292. error.value = false
  293. if (shouldReload) {
  294. currentPage.value = 1
  295. totalPages.value = 0
  296. pageInput.value = 1
  297. scale.value = 2.0
  298. actualFileSize.value = 0
  299. pdfLoaded.value = false
  300. }
  301. loading.value = true
  302. try {
  303. console.log('开始获取PDF文件流...')
  304. const data = {
  305. id: props.id,
  306. // type: '1'
  307. }
  308. // if (props.isEditOtherFileFlag == false) {
  309. // delete data.type
  310. // }
  311. const response = await PipeTaskOrderApi.getIssueReportUseLogoPreviewApi(data, {
  312. responseType: 'blob',
  313. headers: {
  314. 'Accept': 'application/pdf, application/octet-stream, */*'
  315. }
  316. })
  317. console.log('获取到文件流响应:', response)
  318. let blob
  319. if (response instanceof Blob) {
  320. blob = response
  321. if (!blob.type.includes('pdf') && !blob.type.includes('octet-stream') && blob.type !== '') {
  322. if (blob.type.includes('text') || blob.type.includes('xml') || blob.type.includes('html')) {
  323. const text = await blob.text()
  324. console.error('API返回非PDF数据:', text)
  325. throw new Error('服务器返回的不是PDF文件,可能是错误信息')
  326. }
  327. }
  328. if (!blob.type.includes('pdf')) {
  329. blob = new Blob([blob], { type: 'application/pdf' })
  330. }
  331. } else if (response instanceof ArrayBuffer) {
  332. blob = new Blob([response], { type: 'application/pdf' })
  333. } else if (response.data) {
  334. if (response.data instanceof Blob) {
  335. blob = response.data
  336. if (!blob.type.includes('pdf')) {
  337. blob = new Blob([blob], { type: 'application/pdf' })
  338. }
  339. } else if (response.data instanceof ArrayBuffer) {
  340. blob = new Blob([response.data], { type: 'application/pdf' })
  341. } else {
  342. if (typeof response.data === 'string') {
  343. console.error('API返回字符串数据:', response.data)
  344. throw new Error('服务器返回的不是PDF文件数据')
  345. }
  346. blob = new Blob([response.data], { type: 'application/pdf' })
  347. }
  348. }
  349. else {
  350. if (typeof response === 'string') {
  351. console.error('API返回字符串数据:', response)
  352. throw new Error('服务器返回的不是PDF文件数据')
  353. }
  354. blob = new Blob([response], { type: 'application/pdf' })
  355. }
  356. console.log('创建的Blob:', blob, 'size:', blob.size, 'type:', blob.type)
  357. if (!blob || blob.size === 0) {
  358. throw new Error('获取到的文件为空')
  359. }
  360. await validatePdfBlob(blob)
  361. actualFileSize.value = blob.size
  362. console.log('自动获取的文件大小:', formatFileSize(blob.size))
  363. const blobUrl = URL.createObjectURL(blob)
  364. pdfBlobUrl.value = blobUrl
  365. console.log('创建的Blob URL:', blobUrl)
  366. await nextTick()
  367. } catch (error) {
  368. console.error('PDF加载失败:', error)
  369. handlePdfError(error)
  370. ElMessage.error('PDF文件加载失败: ' + (error.message || '未知错误'))
  371. } finally {
  372. loading.value = false
  373. }
  374. }
  375. const validatePdfBlob = async (blob) => {
  376. return new Promise((resolve, reject) => {
  377. const reader = new FileReader()
  378. reader.onload = function(e) {
  379. const arrayBuffer = e.target.result
  380. const uint8Array = new Uint8Array(arrayBuffer.slice(0, 10))
  381. const pdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D]
  382. const headerMatch = pdfHeader.every((byte, index) => uint8Array[index] === byte)
  383. if (!headerMatch) {
  384. const text = new TextDecoder().decode(uint8Array)
  385. if (text.includes('<') || text.includes('<?xml')) {
  386. reject(new Error('服务器返回的是XML/HTML错误响应,不是PDF文件'))
  387. return
  388. }
  389. reject(new Error('文件格式不正确,不是有效的PDF文件'))
  390. return
  391. }
  392. resolve()
  393. }
  394. reader.onerror = function() {
  395. reject(new Error('文件读取失败'))
  396. }
  397. reader.readAsArrayBuffer(blob.slice(0, 1024))
  398. })
  399. }
  400. // PDF事件处理
  401. const handlePdfLoaded = (pdf) => {
  402. console.log('PDF加载成功:', pdf)
  403. loading.value = false
  404. error.value = false
  405. totalPages.value = pdf.numPages
  406. pdfLoaded.value = true
  407. if (currentPage.value === 0 || currentPage.value > pdf.numPages) {
  408. currentPage.value = 1
  409. pageInput.value = 1
  410. }
  411. // ElMessage.success('PDF加载成功')
  412. }
  413. // 新增:处理页面渲染完成
  414. const handlePageRendered = () => {
  415. isPageChanging.value = false
  416. }
  417. const handlePdfError = (errorInfo) => {
  418. console.error('PDF渲染错误:', errorInfo)
  419. loading.value = false
  420. error.value = true
  421. pdfLoaded.value = false
  422. isPageChanging.value = false
  423. ElMessage.error('PDF渲染失败,请尝试刷新或在浏览器中直接打开')
  424. }
  425. const handlePdfProgress = (progressParams) => {
  426. console.log('PDF加载进度:', progressParams)
  427. }
  428. // 优化页面导航 - 防止闪烁
  429. const goToPage = (page) => {
  430. if (page >= 1 && page <= totalPages.value && !isPageChanging.value) {
  431. isPageChanging.value = true
  432. currentPage.value = page
  433. pageInput.value = page
  434. // 给一个小延迟让过渡动画更平滑
  435. setTimeout(() => {
  436. if (isPageChanging.value) {
  437. isPageChanging.value = false
  438. }
  439. }, 300)
  440. }
  441. }
  442. const goToInputPage = () => {
  443. const page = parseInt(pageInput.value)
  444. if (isNaN(page) || page < 1 || page > totalPages.value) {
  445. pageInput.value = currentPage.value
  446. ElMessage.warning('请输入有效的页码')
  447. return
  448. }
  449. goToPage(page)
  450. }
  451. // 其他事件处理函数
  452. const refreshPreview = () => {
  453. pdfLoaded.value = false
  454. loadPreview()
  455. }
  456. const toggleFullscreen = () => {
  457. isFullscreen.value = !isFullscreen.value
  458. }
  459. const openPdfInNewWindow = () => {
  460. if (!pdfBlobUrl.value) return
  461. try {
  462. window.open(pdfBlobUrl.value, '_blank', 'noopener,noreferrer')
  463. ElMessage.success('已在新窗口打开PDF文件')
  464. } catch (error) {
  465. ElMessage.error('新窗口打开失败,请检查浏览器设置')
  466. }
  467. }
  468. const downloadFile = () => {
  469. if (!pdfBlobUrl.value) {
  470. ElMessage.warning('文件尚未加载完成')
  471. return
  472. }
  473. try {
  474. const link = document.createElement('a')
  475. link.href = pdfBlobUrl.value
  476. let filename = fileInfo.value.name
  477. if (!filename.toLowerCase().endsWith('.pdf')) {
  478. filename += '.pdf'
  479. }
  480. link.download = filename
  481. link.target = '_blank'
  482. link.rel = 'noopener noreferrer'
  483. document.body.appendChild(link)
  484. link.click()
  485. document.body.removeChild(link)
  486. ElMessage.success('文件下载已开始')
  487. } catch (error) {
  488. console.error('下载失败:', error)
  489. ElMessage.error('下载失败,请重试')
  490. }
  491. }
  492. // 缩放控制
  493. const zoomIn = () => {
  494. if (scale.value < 3) {
  495. scale.value = Math.min(Math.round((scale.value * 1.2) * 10) / 10, 3)
  496. }
  497. }
  498. const zoomOut = () => {
  499. if (scale.value > 0.5) {
  500. scale.value = Math.max(Math.round((scale.value / 1.2) * 10) / 10, 0.5)
  501. }
  502. }
  503. const resetZoom = () => {
  504. scale.value = 2.0
  505. }
  506. // 键盘快捷键支持
  507. const handleKeydown = (event) => {
  508. if (!visible.value) return
  509. if (event.ctrlKey || event.metaKey) {
  510. if (event.key === '=' || event.key === '+') {
  511. event.preventDefault()
  512. zoomIn()
  513. } else if (event.key === '-') {
  514. event.preventDefault()
  515. zoomOut()
  516. } else if (event.key === '0') {
  517. event.preventDefault()
  518. resetZoom()
  519. }
  520. }
  521. // 支持方向键翻页
  522. if (event.key === 'ArrowLeft' && currentPage.value > 1) {
  523. event.preventDefault()
  524. goToPage(currentPage.value - 1)
  525. } else if (event.key === 'ArrowRight' && currentPage.value < totalPages.value) {
  526. event.preventDefault()
  527. goToPage(currentPage.value + 1)
  528. }
  529. }
  530. const handleClose = () => {
  531. visible.value = false
  532. emit('update:modelValue', false)
  533. emit('close')
  534. // 清理时保留blob URL,因为对话框可能会再次打开
  535. // 只在组件销毁时清理
  536. }
  537. // 监听器
  538. watch(
  539. () => props.modelValue,
  540. (newVal) => {
  541. visible.value = newVal
  542. if (newVal && !pdfBlobUrl.value) {
  543. loadPreview()
  544. }
  545. },
  546. { immediate: true }
  547. )
  548. onMounted(() => {
  549. document.addEventListener('keydown', handleKeydown)
  550. })
  551. // 清理
  552. onUnmounted(() => {
  553. document.removeEventListener('keydown', handleKeydown)
  554. if (pdfBlobUrl.value) {
  555. URL.revokeObjectURL(pdfBlobUrl.value)
  556. }
  557. })
  558. </script>
  559. <style scoped lang="scss">
  560. $a4-width: 210mm;
  561. $a4-height: 297mm;
  562. $a4-ratio: 1.414;
  563. $page-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
  564. $page-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.1);
  565. .pdf-wrapper {
  566. transition: transform 0.3s ease;
  567. display: flex;
  568. justify-content: center;
  569. padding: 20px 0;
  570. }
  571. .pdf-embed {
  572. border-radius: 4px;
  573. box-shadow: $page-shadow;
  574. background: #fff;
  575. transition: all 0.3s ease;
  576. &:hover {
  577. box-shadow: $page-shadow-hover;
  578. }
  579. }
  580. .preview-header {
  581. display: flex;
  582. justify-content: space-between;
  583. align-items: center;
  584. padding: 0;
  585. background: #f8f9fa;
  586. border-radius: 8px 8px 0 0;
  587. margin: -20px -20px 20px -20px;
  588. padding: 16px 20px;
  589. border-bottom: 1px solid #e4e7ed;
  590. }
  591. .preview-title-section {
  592. display: flex;
  593. align-items: center;
  594. gap: 12px;
  595. flex: 1;
  596. }
  597. .preview-title-section h4 {
  598. margin: 0;
  599. display: flex;
  600. align-items: center;
  601. gap: 8px;
  602. font-size: 16px;
  603. font-weight: 600;
  604. color: #303133;
  605. }
  606. .title-icon {
  607. color: #e74c3c;
  608. font-size: 18px;
  609. }
  610. .file-size-info {
  611. color: #909399;
  612. font-size: 12px;
  613. background: rgba(64, 158, 255, 0.1);
  614. color: #409eff;
  615. padding: 4px 8px;
  616. border-radius: 12px;
  617. font-weight: 500;
  618. }
  619. .preview-controls {
  620. display: flex;
  621. gap: 8px;
  622. }
  623. .preview-container {
  624. position: relative;
  625. min-height: 600px;
  626. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  627. border-radius: 12px;
  628. padding: 20px;
  629. margin: 0 -20px;
  630. display: flex;
  631. justify-content: center;
  632. align-items: center;
  633. }
  634. .preview-content {
  635. width: 100%;
  636. height: 100%;
  637. display: flex;
  638. justify-content: center;
  639. align-items: flex-start;
  640. }
  641. .pdf-content {
  642. flex-direction: column;
  643. align-items: center;
  644. gap: 20px;
  645. width: 100%;
  646. max-width: 900px;
  647. }
  648. .pdf-embed-container {
  649. width: 100%;
  650. display: flex;
  651. flex-direction: column;
  652. gap: 20px;
  653. align-items: center;
  654. }
  655. :deep(.vue-pdf-embed) {
  656. background: transparent !important;
  657. .vue-pdf-embed__page {
  658. margin: 0 auto 20px;
  659. background: #ffffff;
  660. box-shadow: $page-shadow;
  661. border-radius: 8px;
  662. padding: 0;
  663. transition: all 0.3s ease;
  664. position: relative;
  665. overflow: hidden;
  666. &::before {
  667. content: '';
  668. position: absolute;
  669. top: 0;
  670. left: 0;
  671. right: 0;
  672. bottom: 0;
  673. background: linear-gradient(
  674. 45deg,
  675. transparent 49%,
  676. rgba(255, 255, 255, 0.03) 49%,
  677. rgba(255, 255, 255, 0.03) 51%,
  678. transparent 51%
  679. );
  680. pointer-events: none;
  681. z-index: 1;
  682. }
  683. &:hover {
  684. box-shadow: $page-shadow-hover;
  685. transform: translateY(-2px);
  686. }
  687. canvas {
  688. display: block;
  689. border-radius: 8px;
  690. position: relative;
  691. z-index: 0;
  692. }
  693. }
  694. .vue-pdf-embed__container {
  695. background: transparent !important;
  696. }
  697. }
  698. .pdf-controls {
  699. display: flex;
  700. justify-content: space-between;
  701. align-items: center;
  702. flex-wrap: wrap;
  703. gap: 12px;
  704. padding: 16px 20px;
  705. background: rgba(255, 255, 255, 0.95);
  706. backdrop-filter: blur(10px);
  707. border-radius: 12px;
  708. border: 1px solid rgba(255, 255, 255, 0.2);
  709. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  710. position: sticky;
  711. bottom: 20px;
  712. z-index: 100;
  713. margin: 0 auto;
  714. width: 100%;
  715. max-width: 900px;
  716. }
  717. .page-input {
  718. width: 60px;
  719. text-align: center;
  720. :deep(.el-input__inner) {
  721. text-align: center;
  722. border-radius: 6px;
  723. font-weight: 500;
  724. }
  725. }
  726. .page-separator {
  727. margin: 0 8px;
  728. color: #909399;
  729. font-weight: 500;
  730. }
  731. .total-pages {
  732. color: #606266;
  733. font-weight: 600;
  734. }
  735. .zoom-controls {
  736. display: flex;
  737. align-items: center;
  738. gap: 8px;
  739. }
  740. .action-controls {
  741. display: flex;
  742. align-items: center;
  743. gap: 8px;
  744. }
  745. .zoom-display {
  746. min-width: 55px;
  747. text-align: center;
  748. font-size: 14px;
  749. color: #606266;
  750. font-weight: 600;
  751. background: rgba(64, 158, 255, 0.1);
  752. padding: 4px 8px;
  753. border-radius: 6px;
  754. color: #409eff;
  755. }
  756. .preview-error {
  757. display: flex;
  758. flex-direction: column;
  759. align-items: center;
  760. justify-content: center;
  761. padding: 60px 20px;
  762. text-align: center;
  763. color: #606266;
  764. height: 100%;
  765. min-height: 400px;
  766. background: rgba(255, 255, 255, 0.8);
  767. border-radius: 12px;
  768. backdrop-filter: blur(10px);
  769. }
  770. .error-title {
  771. font-size: 20px;
  772. font-weight: 600;
  773. margin: 20px 0 12px;
  774. color: #f56c6c;
  775. }
  776. .error-tip {
  777. margin: 16px 0 12px;
  778. font-size: 15px;
  779. color: #909399;
  780. font-weight: 500;
  781. }
  782. .error-reasons {
  783. text-align: left;
  784. margin: 16px 0 24px;
  785. padding: 16px 20px;
  786. background: rgba(245, 108, 108, 0.05);
  787. border-radius: 8px;
  788. border-left: 4px solid #f56c6c;
  789. li {
  790. margin: 6px 0;
  791. font-size: 14px;
  792. color: #606266;
  793. line-height: 1.5;
  794. }
  795. }
  796. .error-actions {
  797. display: flex;
  798. gap: 12px;
  799. margin-top: 24px;
  800. }
  801. :deep(.el-loading-mask) {
  802. background-color: rgba(248, 249, 250, 0.95);
  803. backdrop-filter: blur(5px);
  804. border-radius: 12px;
  805. .el-loading-spinner {
  806. .el-loading-text {
  807. color: #606266;
  808. font-weight: 500;
  809. margin-top: 16px;
  810. }
  811. .circular {
  812. width: 50px;
  813. height: 50px;
  814. color: #409eff;
  815. }
  816. }
  817. }
  818. @media (max-width: 1200px) {
  819. .preview-container {
  820. padding: 15px;
  821. }
  822. .pdf-controls {
  823. max-width: 100%;
  824. margin: 0 15px;
  825. }
  826. }
  827. @media (max-width: 768px) {
  828. .preview-header {
  829. flex-direction: column;
  830. gap: 12px;
  831. align-items: stretch;
  832. padding: 12px 16px;
  833. }
  834. .preview-controls {
  835. justify-content: center;
  836. }
  837. .pdf-controls {
  838. flex-direction: column;
  839. gap: 16px;
  840. padding: 16px;
  841. position: relative;
  842. bottom: auto;
  843. margin: 20px 0 0;
  844. .el-button-group {
  845. width: 100%;
  846. justify-content: center;
  847. }
  848. }
  849. .preview-container {
  850. padding: 10px;
  851. margin: 0 -10px;
  852. }
  853. }
  854. @media (max-width: 480px) {
  855. .pdf-controls {
  856. .el-button-group {
  857. flex-wrap: wrap;
  858. gap: 8px;
  859. }
  860. .zoom-controls,
  861. .action-controls {
  862. flex-wrap: wrap;
  863. justify-content: center;
  864. }
  865. }
  866. .preview-header {
  867. padding: 10px 12px;
  868. }
  869. .preview-title-section h4 {
  870. font-size: 14px;
  871. }
  872. }
  873. :deep(.pdf-preview-dialog) {
  874. .el-dialog__body {
  875. background: #ffffff;
  876. }
  877. .el-dialog__header {
  878. padding: 0;
  879. margin: 0;
  880. border-bottom: none;
  881. }
  882. .el-dialog__headerbtn {
  883. top: 16px;
  884. right: 16px;
  885. width: 32px;
  886. height: 32px;
  887. background: rgba(255, 255, 255, 0.9);
  888. border-radius: 50%;
  889. transition: all 0.3s ease;
  890. &:hover {
  891. background: rgba(245, 108, 108, 0.1);
  892. .el-dialog__close {
  893. color: #f56c6c;
  894. }
  895. }
  896. }
  897. .el-dialog__footer {
  898. padding: 0;
  899. border-top: none;
  900. }
  901. }
  902. :deep(.is-fullscreen) {
  903. .preview-container {
  904. min-height: calc(100vh - 200px);
  905. }
  906. .pdf-controls {
  907. position: fixed;
  908. bottom: 30px;
  909. left: 50%;
  910. transform: translateX(-50%);
  911. z-index: 1000;
  912. }
  913. }
  914. </style>