fix(review-tablet): 修复平板文件预览兼容性
parent
25eef30456
commit
2179e7d9d2
|
|
@ -0,0 +1,15 @@
|
|||
import type { IncomingMessage } from 'node:http'
|
||||
|
||||
type ProxyRequestLike = {
|
||||
setHeader: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const forwardOriginalHost = (
|
||||
proxyReq: ProxyRequestLike,
|
||||
req: IncomingMessage
|
||||
) => {
|
||||
const host = req.headers.host
|
||||
if (typeof host === 'string' && host.trim()) {
|
||||
proxyReq.setHeader('host', host)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
"min-dash": "^4.1.1",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"qrcode": "^1.5.3",
|
||||
|
|
|
|||
141
pnpm-lock.yaml
141
pnpm-lock.yaml
|
|
@ -119,6 +119,9 @@ importers:
|
|||
nprogress:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
pdfjs-dist:
|
||||
specifier: ^5.6.205
|
||||
version: 5.6.205
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.3.1(typescript@5.3.3)(vue@3.5.12(typescript@5.3.3))
|
||||
|
|
@ -1405,6 +1408,81 @@ packages:
|
|||
'@microsoft/fetch-event-source@2.0.1':
|
||||
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.97':
|
||||
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -4224,6 +4302,9 @@ packages:
|
|||
node-html-parser@7.0.2:
|
||||
resolution: {integrity: sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ==}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
|
|
@ -4375,6 +4456,10 @@ packages:
|
|||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdfjs-dist@5.6.205:
|
||||
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
|
||||
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
|
|
@ -6681,6 +6766,54 @@ snapshots:
|
|||
|
||||
'@microsoft/fetch-event-source@2.0.1': {}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.97':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.97
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.97
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.97
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.97
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.97
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.97
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.97
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.97
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.97
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
|
@ -9760,6 +9893,9 @@ snapshots:
|
|||
css-select: 5.2.2
|
||||
he: 1.2.0
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nopt@7.2.1:
|
||||
|
|
@ -9894,6 +10030,11 @@ snapshots:
|
|||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdfjs-dist@5.6.205:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.97
|
||||
node-readable-to-web-readable-stream: 0.4.2
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
type PromiseWithResolversResult<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PromiseConstructor {
|
||||
withResolvers?<T>(): PromiseWithResolversResult<T>
|
||||
}
|
||||
}
|
||||
|
||||
export const ensurePromiseWithResolvers = () => {
|
||||
if (typeof Promise.withResolvers === 'function') return Promise.withResolvers.bind(Promise)
|
||||
|
||||
Promise.withResolvers = function withResolvers<T>(): PromiseWithResolversResult<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
return Promise.withResolvers.bind(Promise)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
declare global {
|
||||
interface URLConstructor {
|
||||
parse?(url: string | URL, base?: string | URL): URL | null
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureUrlParse = () => {
|
||||
if (typeof URL.parse === 'function') return URL.parse.bind(URL)
|
||||
|
||||
URL.parse = function parse(url: string | URL, base?: string | URL) {
|
||||
try {
|
||||
return typeof base === 'undefined' ? new URL(url) : new URL(url, base)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return URL.parse.bind(URL)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
<template>
|
||||
<div class="tablet-pdf-viewer">
|
||||
<aside class="pdf-outline">
|
||||
<div class="pdf-outline-head">书签</div>
|
||||
<div v-if="outlineItems.length === 0" class="pdf-outline-empty">暂无书签</div>
|
||||
<div v-else class="pdf-outline-list">
|
||||
<button
|
||||
v-for="item in outlineItems"
|
||||
:key="item.id"
|
||||
class="pdf-outline-item"
|
||||
:style="{ paddingLeft: `${8 + item.level * 14}px` }"
|
||||
@click="jumpByOutline(item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="pdf-stage">
|
||||
<div class="pdf-toolbar">
|
||||
<el-button text size="small" :disabled="currentPage <= 1 || busy" @click="prevPage"
|
||||
>上一页</el-button
|
||||
>
|
||||
<span class="pdf-page-indicator">{{ currentPage }} / {{ totalPages || '-' }}</span>
|
||||
<el-button
|
||||
text
|
||||
size="small"
|
||||
:disabled="!totalPages || currentPage >= totalPages || busy"
|
||||
@click="nextPage"
|
||||
>下一页</el-button
|
||||
>
|
||||
<el-button text size="small" :disabled="busy" @click="zoomOut">缩小</el-button>
|
||||
<el-button text size="small" :disabled="busy" @click="zoomIn">放大</el-button>
|
||||
<el-button text size="small" :disabled="busy" @click="fitToWidth">适应宽度</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="pdf-error">{{ errorMessage }}</div>
|
||||
<div v-else ref="canvasWrapRef" class="pdf-canvas-wrap">
|
||||
<canvas ref="canvasRef" class="pdf-canvas"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
import { ensurePromiseWithResolvers } from '@/utils/promiseWithResolvers'
|
||||
import { ensureUrlParse } from '@/utils/urlParse'
|
||||
|
||||
interface PdfOutlineItem {
|
||||
id: string
|
||||
title: string
|
||||
level: number
|
||||
dest: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'loading-change', loading: boolean): void
|
||||
(e: 'error', message: string): void
|
||||
}>()
|
||||
|
||||
const loadingDoc = ref(false)
|
||||
const renderingPage = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const totalPages = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const currentScale = ref(1)
|
||||
const outlineItems = ref<PdfOutlineItem[]>([])
|
||||
const fitWidthMode = ref(true)
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const canvasWrapRef = ref<HTMLDivElement>()
|
||||
const busy = computed(() => loadingDoc.value || renderingPage.value)
|
||||
|
||||
let pdfDoc: any = null
|
||||
let renderToken = 0
|
||||
let activeRenderTask: any = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pdfjsModulePromise: Promise<any> | null = null
|
||||
|
||||
watch(
|
||||
() => busy.value,
|
||||
(value) => {
|
||||
emit('loading-change', value)
|
||||
}
|
||||
)
|
||||
|
||||
const destroyPdfDoc = async () => {
|
||||
if (!pdfDoc) return
|
||||
try {
|
||||
await pdfDoc.destroy()
|
||||
} catch {
|
||||
// ignore destroy errors
|
||||
} finally {
|
||||
pdfDoc = null
|
||||
}
|
||||
}
|
||||
|
||||
const flattenOutline = (items: any[], level = 0, path = '0'): PdfOutlineItem[] =>
|
||||
(items || []).flatMap((item: any, index: number) => {
|
||||
const currentId = `${path}-${index}`
|
||||
const title = String(item?.title || '未命名书签').trim() || '未命名书签'
|
||||
const current: PdfOutlineItem = {
|
||||
id: currentId,
|
||||
title,
|
||||
level,
|
||||
dest: item?.dest
|
||||
}
|
||||
const children = flattenOutline(item?.items || [], level + 1, currentId)
|
||||
return [current, ...children]
|
||||
})
|
||||
|
||||
const loadOutline = async () => {
|
||||
if (!pdfDoc) {
|
||||
outlineItems.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const outline = await pdfDoc.getOutline()
|
||||
outlineItems.value = flattenOutline(outline || [])
|
||||
} catch {
|
||||
outlineItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const setError = (message: string) => {
|
||||
errorMessage.value = message
|
||||
emit('error', message)
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
if (!errorMessage.value) return
|
||||
errorMessage.value = ''
|
||||
emit('error', '')
|
||||
}
|
||||
|
||||
const loadPdfJs = async () => {
|
||||
ensurePromiseWithResolvers()
|
||||
ensureUrlParse()
|
||||
if (!pdfjsModulePromise) {
|
||||
pdfjsModulePromise = import('pdfjs-dist/build/pdf.mjs').then((module) => {
|
||||
;(module as any).GlobalWorkerOptions.workerSrc = workerSrc
|
||||
return module
|
||||
})
|
||||
}
|
||||
return pdfjsModulePromise
|
||||
}
|
||||
|
||||
const renderCurrentPage = async () => {
|
||||
if (!pdfDoc || !canvasRef.value || !canvasWrapRef.value) return
|
||||
const token = ++renderToken
|
||||
renderingPage.value = true
|
||||
clearError()
|
||||
if (activeRenderTask) {
|
||||
try {
|
||||
activeRenderTask.cancel()
|
||||
} catch {
|
||||
// ignore cancel errors
|
||||
}
|
||||
activeRenderTask = null
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await pdfDoc.getPage(currentPage.value)
|
||||
if (token !== renderToken || !canvasRef.value || !canvasWrapRef.value) return
|
||||
|
||||
const baseViewport = page.getViewport({ scale: 1 })
|
||||
let scale = currentScale.value
|
||||
if (fitWidthMode.value) {
|
||||
const wrapWidth = Math.max(canvasWrapRef.value.clientWidth - 24, 320)
|
||||
scale = wrapWidth / baseViewport.width
|
||||
}
|
||||
scale = Math.min(Math.max(scale, 0.6), 3)
|
||||
currentScale.value = scale
|
||||
|
||||
const viewport = page.getViewport({ scale })
|
||||
const canvas = canvasRef.value
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('无法获取画布上下文')
|
||||
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
canvas.width = Math.floor(viewport.width * ratio)
|
||||
canvas.height = Math.floor(viewport.height * ratio)
|
||||
canvas.style.width = `${Math.floor(viewport.width)}px`
|
||||
canvas.style.height = `${Math.floor(viewport.height)}px`
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
|
||||
activeRenderTask = page.render({ canvasContext: context, viewport })
|
||||
await activeRenderTask.promise
|
||||
activeRenderTask = null
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'RenderingCancelledException') return
|
||||
setError(error?.message || 'PDF 渲染失败,请稍后重试')
|
||||
} finally {
|
||||
if (token === renderToken) renderingPage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveOutlineDest = async (dest: unknown) => {
|
||||
if (!pdfDoc || !dest) return null
|
||||
if (typeof dest === 'string') {
|
||||
return pdfDoc.getDestination(dest)
|
||||
}
|
||||
if (Array.isArray(dest)) return dest
|
||||
return null
|
||||
}
|
||||
|
||||
const jumpByOutline = async (item: PdfOutlineItem) => {
|
||||
if (!pdfDoc) return
|
||||
const dest = await resolveOutlineDest(item.dest)
|
||||
if (!Array.isArray(dest) || !dest[0]) return
|
||||
try {
|
||||
const pageIndex = await pdfDoc.getPageIndex(dest[0])
|
||||
currentPage.value = pageIndex + 1
|
||||
await renderCurrentPage()
|
||||
} catch {
|
||||
// ignore invalid outline destinations
|
||||
}
|
||||
}
|
||||
|
||||
const zoomIn = async () => {
|
||||
fitWidthMode.value = false
|
||||
currentScale.value = Math.min(currentScale.value + 0.2, 3)
|
||||
await renderCurrentPage()
|
||||
}
|
||||
|
||||
const zoomOut = async () => {
|
||||
fitWidthMode.value = false
|
||||
currentScale.value = Math.max(currentScale.value - 0.2, 0.6)
|
||||
await renderCurrentPage()
|
||||
}
|
||||
|
||||
const fitToWidth = async () => {
|
||||
fitWidthMode.value = true
|
||||
await renderCurrentPage()
|
||||
}
|
||||
|
||||
const prevPage = async () => {
|
||||
if (currentPage.value <= 1) return
|
||||
currentPage.value -= 1
|
||||
await renderCurrentPage()
|
||||
}
|
||||
|
||||
const nextPage = async () => {
|
||||
if (!totalPages.value || currentPage.value >= totalPages.value) return
|
||||
currentPage.value += 1
|
||||
await renderCurrentPage()
|
||||
}
|
||||
|
||||
const loadPdf = async () => {
|
||||
renderToken += 1
|
||||
loadingDoc.value = true
|
||||
totalPages.value = 0
|
||||
currentPage.value = 1
|
||||
currentScale.value = 1
|
||||
fitWidthMode.value = true
|
||||
outlineItems.value = []
|
||||
clearError()
|
||||
await destroyPdfDoc()
|
||||
|
||||
if (!props.src) {
|
||||
loadingDoc.value = false
|
||||
setError('未获取到 PDF 预览地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfjs = await loadPdfJs()
|
||||
const task = pdfjs.getDocument({ url: props.src, withCredentials: true })
|
||||
pdfDoc = await task.promise
|
||||
totalPages.value = pdfDoc?.numPages || 0
|
||||
await loadOutline()
|
||||
await nextTick()
|
||||
await renderCurrentPage()
|
||||
} catch (error: any) {
|
||||
setError(error?.message || 'PDF 加载失败,请稍后重试')
|
||||
} finally {
|
||||
loadingDoc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleFitWidthRender = () => {
|
||||
if (!fitWidthMode.value) return
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
renderCurrentPage()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
loadPdf()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!canvasWrapRef.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(() => scheduleFitWidthRender())
|
||||
resizeObserver.observe(canvasWrapRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
renderToken += 1
|
||||
if (activeRenderTask) {
|
||||
try {
|
||||
activeRenderTask.cancel()
|
||||
} catch {
|
||||
// ignore cancel errors
|
||||
}
|
||||
activeRenderTask = null
|
||||
}
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
await destroyPdfDoc()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tablet-pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||
background: #f3f6fb;
|
||||
}
|
||||
|
||||
.pdf-outline {
|
||||
border-right: 1px solid #cbd6e5;
|
||||
background: #eaf0f8;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pdf-outline-head {
|
||||
padding: 10px 10px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #3b4e69;
|
||||
border-bottom: 1px solid #d2dceb;
|
||||
}
|
||||
|
||||
.pdf-outline-empty {
|
||||
padding: 12px 10px;
|
||||
color: #73839b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pdf-outline-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px 4px 8px;
|
||||
}
|
||||
|
||||
.pdf-outline-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #35485f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pdf-outline-item:hover {
|
||||
background: #dde7f6;
|
||||
}
|
||||
|
||||
.pdf-stage {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.pdf-toolbar {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid #d4deec;
|
||||
background: #edf2f9;
|
||||
}
|
||||
|
||||
.pdf-page-indicator {
|
||||
min-width: 62px;
|
||||
text-align: center;
|
||||
color: #53647d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pdf-error {
|
||||
margin: auto;
|
||||
color: #b74857;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pdf-canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pdf-canvas {
|
||||
display: block;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(35, 57, 94, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.tablet-pdf-viewer {
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.tablet-pdf-viewer {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pdf-outline {
|
||||
max-height: 160px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #cbd6e5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
<el-button text :icon="Refresh" :disabled="!activeFileId" @click="refreshPreview"
|
||||
>刷新预览</el-button
|
||||
>
|
||||
<el-button text :icon="Link" :disabled="!previewUrl" @click="openInNewWindow"
|
||||
<el-button text :icon="Link" :disabled="!newWindowPreviewUrl" @click="openInNewWindow"
|
||||
>新窗口打开</el-button
|
||||
>
|
||||
<el-button v-if="rightCollapsed" text @click="openRightPanel('ai')">打开侧栏</el-button>
|
||||
|
|
@ -441,7 +441,11 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||
import MarkdownIt from 'markdown-it'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import TabletPdfViewer from './TabletPdfViewer.vue'
|
||||
import { resolvePdfSourceUrl, resolveTabletPreviewMode } from './previewMode.mjs'
|
||||
import {
|
||||
resolveNewWindowPreviewUrl,
|
||||
resolvePdfSourceUrl,
|
||||
resolveTabletPreviewMode
|
||||
} from './previewMode.mjs'
|
||||
import {
|
||||
getFileOpenUrl,
|
||||
getProjectFiles,
|
||||
|
|
@ -627,7 +631,14 @@ const previewLoadingSubtitle = computed(() =>
|
|||
? 'Office 文件首次打开可能需要一些时间,请稍候'
|
||||
: '正在为您打开当前资料'
|
||||
)
|
||||
const previewMode = computed(() => resolveTabletPreviewMode(previewPayload.value))
|
||||
const previewMode = computed(() =>
|
||||
resolveTabletPreviewMode(previewPayload.value, {
|
||||
preferIframeForPdf: isTouchTablet.value && !!previewPayload.value?.openUrl
|
||||
})
|
||||
)
|
||||
const newWindowPreviewUrl = computed(() =>
|
||||
resolveNewWindowPreviewUrl(previewPayload.value, previewUrl.value)
|
||||
)
|
||||
const isTouchTablet = computed(() => coarsePointer.value)
|
||||
const isCompactTablet = computed(() => coarsePointer.value && viewportWidth.value <= 1400)
|
||||
const isDenseTablet = computed(() => coarsePointer.value && viewportWidth.value <= 1180)
|
||||
|
|
@ -786,8 +797,11 @@ const loadPreview = async (fileId: number) => {
|
|||
const payload = await getFileOpenUrl(fileId)
|
||||
if (requestToken !== previewRequestToken.value) return
|
||||
previewPayload.value = payload
|
||||
const mode = resolveTabletPreviewMode(payload)
|
||||
const sourceUrl = resolvePdfSourceUrl(payload, payload.openUrl)
|
||||
const mode = resolveTabletPreviewMode(payload, {
|
||||
preferIframeForPdf: isTouchTablet.value && !!payload.openUrl
|
||||
})
|
||||
const sourceUrl =
|
||||
mode === 'pdf' ? resolvePdfSourceUrl(payload, payload.openUrl) : payload.openUrl || ''
|
||||
if (!sourceUrl) {
|
||||
previewUrl.value = ''
|
||||
previewFrameLoading.value = false
|
||||
|
|
@ -822,7 +836,7 @@ const selectFileNode = async (fileNode: FileTreeNode) => {
|
|||
}
|
||||
|
||||
const openInNewWindow = () => {
|
||||
const url = previewUrl.value || previewPayload.value?.openUrl
|
||||
const url = newWindowPreviewUrl.value
|
||||
if (!url) {
|
||||
ElMessage.warning('暂无可打开的预览地址')
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
const PDF_EXT = 'pdf'
|
||||
const PREVIEW_SOURCE_PATH = '/admin-api/project/review-tablet/file-preview-source/'
|
||||
const CLIENT_UNREACHABLE_HOSTS = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'0.0.0.0',
|
||||
'host.docker.internal'
|
||||
])
|
||||
|
||||
const normalizeFileType = (value) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const toSameOriginPreviewPathIfNeeded = (url) => {
|
||||
const normalized = String(url || '').trim()
|
||||
if (!normalized) return ''
|
||||
if (normalized.startsWith(PREVIEW_SOURCE_PATH)) return normalized
|
||||
try {
|
||||
const parsed = new URL(normalized)
|
||||
if (!parsed.pathname.startsWith(PREVIEW_SOURCE_PATH)) return normalized
|
||||
if (!CLIENT_UNREACHABLE_HOSTS.has(parsed.hostname.toLowerCase())) return normalized
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
} catch {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveTabletPreviewMode = (payload, options = {}) => {
|
||||
if (!payload) return 'none'
|
||||
if (options.preferIframeForPdf && normalizeFileType(payload.fileType) === PDF_EXT) {
|
||||
return 'iframe'
|
||||
}
|
||||
return normalizeFileType(payload.fileType) === PDF_EXT ? 'pdf' : 'iframe'
|
||||
}
|
||||
|
||||
export const resolvePdfSourceUrl = (payload, fallbackUrl = '') => {
|
||||
if (!payload) return fallbackUrl || ''
|
||||
if (resolveTabletPreviewMode(payload) !== 'pdf') return fallbackUrl || ''
|
||||
const visitUrl = toSameOriginPreviewPathIfNeeded(payload.visitUrl)
|
||||
return visitUrl || fallbackUrl || ''
|
||||
}
|
||||
|
||||
export const resolveNewWindowPreviewUrl = (payload, fallbackUrl = '') => {
|
||||
if (!payload) return fallbackUrl || ''
|
||||
const openUrl = String(payload.openUrl || '').trim()
|
||||
if (resolveTabletPreviewMode(payload) === 'pdf') {
|
||||
return openUrl || resolvePdfSourceUrl(payload, fallbackUrl)
|
||||
}
|
||||
return openUrl || fallbackUrl || ''
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
test('ensurePromiseWithResolvers installs a compatible polyfill when unavailable', async () => {
|
||||
const original = Promise.withResolvers
|
||||
try {
|
||||
Promise.withResolvers = undefined
|
||||
const { ensurePromiseWithResolvers } = await import('../src/utils/promiseWithResolvers.ts')
|
||||
|
||||
ensurePromiseWithResolvers()
|
||||
|
||||
assert.equal(typeof Promise.withResolvers, 'function')
|
||||
const deferred = Promise.withResolvers()
|
||||
assert.equal(typeof deferred.promise?.then, 'function')
|
||||
assert.equal(typeof deferred.resolve, 'function')
|
||||
assert.equal(typeof deferred.reject, 'function')
|
||||
} finally {
|
||||
Promise.withResolvers = original
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
test('pdf files resolve to local pdf viewer mode and prefer visitUrl as source', async () => {
|
||||
const { resolveTabletPreviewMode, resolvePdfSourceUrl } =
|
||||
await import('../src/views/review/tablet/previewMode.mjs')
|
||||
const payload = {
|
||||
fileType: 'pdf',
|
||||
visitUrl: '/admin-api/project/review-tablet/file-preview-source/12/preview-12.pdf?sign=abc',
|
||||
openUrl: 'http://kkfileview.example.com/onlinePreview?url=xxx'
|
||||
}
|
||||
assert.equal(resolveTabletPreviewMode(payload), 'pdf')
|
||||
assert.equal(resolvePdfSourceUrl(payload, payload.openUrl), payload.visitUrl)
|
||||
})
|
||||
|
||||
test('office files stay on iframe preview mode and keep remote preview url', async () => {
|
||||
const { resolveTabletPreviewMode, resolvePdfSourceUrl } =
|
||||
await import('../src/views/review/tablet/previewMode.mjs')
|
||||
const payload = {
|
||||
fileType: 'docx',
|
||||
visitUrl: '/admin-api/project/review-tablet/file-preview-source/12/preview-12.docx?sign=abc',
|
||||
openUrl: 'http://kkfileview.example.com/onlinePreview?url=xxx'
|
||||
}
|
||||
assert.equal(resolveTabletPreviewMode(payload), 'iframe')
|
||||
assert.equal(resolvePdfSourceUrl(payload, payload.openUrl), payload.openUrl)
|
||||
})
|
||||
|
||||
test('pdf visitUrl with non-client host falls back to same-origin preview-source path', async () => {
|
||||
const { resolvePdfSourceUrl } = await import('../src/views/review/tablet/previewMode.mjs')
|
||||
const payload = {
|
||||
fileType: 'pdf',
|
||||
visitUrl:
|
||||
'http://host.docker.internal:48080/admin-api/project/review-tablet/file-preview-source/12/preview-12.pdf?sign=abc',
|
||||
openUrl: 'http://10.11.74.40:8012/onlinePreview?url=xxx'
|
||||
}
|
||||
assert.equal(
|
||||
resolvePdfSourceUrl(payload, payload.openUrl),
|
||||
'/admin-api/project/review-tablet/file-preview-source/12/preview-12.pdf?sign=abc'
|
||||
)
|
||||
})
|
||||
|
||||
test('pdf new window prefers unified preview url instead of raw source url', async () => {
|
||||
const { resolveNewWindowPreviewUrl } = await import('../src/views/review/tablet/previewMode.mjs')
|
||||
const payload = {
|
||||
fileType: 'pdf',
|
||||
visitUrl: '/admin-api/project/review-tablet/file-preview-source/12/preview-12.pdf?sign=abc',
|
||||
openUrl: 'http://192.168.10.24:8012/onlinePreview?url=xxx'
|
||||
}
|
||||
assert.equal(resolveNewWindowPreviewUrl(payload, payload.visitUrl), payload.openUrl)
|
||||
})
|
||||
|
||||
test('pdf files can fall back to iframe preview mode on touch tablets', async () => {
|
||||
const { resolveTabletPreviewMode } = await import('../src/views/review/tablet/previewMode.mjs')
|
||||
const payload = {
|
||||
fileType: 'pdf',
|
||||
visitUrl: '/admin-api/project/review-tablet/file-preview-source/12/preview-12.pdf?sign=abc',
|
||||
openUrl: 'http://192.168.10.24:8012/onlinePreview?url=xxx'
|
||||
}
|
||||
assert.equal(resolveTabletPreviewMode(payload, { preferIframeForPdf: true }), 'iframe')
|
||||
})
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
test('ensureUrlParse installs a compatible URL.parse polyfill when unavailable', async () => {
|
||||
const original = URL.parse
|
||||
try {
|
||||
URL.parse = undefined
|
||||
const { ensureUrlParse } = await import('../src/utils/urlParse.ts')
|
||||
|
||||
ensureUrlParse()
|
||||
|
||||
assert.equal(typeof URL.parse, 'function')
|
||||
assert.equal(URL.parse('/demo.pdf', 'http://192.168.10.24:48000/base/').href, 'http://192.168.10.24:48000/demo.pdf')
|
||||
assert.equal(URL.parse('not a valid url'), null)
|
||||
} finally {
|
||||
URL.parse = original
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
test('forwardOriginalHost preserves the incoming host header for proxied requests', async () => {
|
||||
const { forwardOriginalHost } = await import('../build/vite/proxy.ts')
|
||||
const calls = []
|
||||
const proxyReq = {
|
||||
setHeader(name, value) {
|
||||
calls.push([name, value])
|
||||
}
|
||||
}
|
||||
const req = {
|
||||
headers: {
|
||||
host: '192.168.10.24:48000'
|
||||
}
|
||||
}
|
||||
|
||||
forwardOriginalHost(proxyReq, req)
|
||||
|
||||
assert.deepEqual(calls, [['host', '192.168.10.24:48000']])
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import {resolve} from 'path'
|
|||
import type {ConfigEnv, UserConfig} from 'vite'
|
||||
import {loadEnv} from 'vite'
|
||||
import {createVitePlugins} from './build/vite'
|
||||
import {forwardOriginalHost} from './build/vite/proxy'
|
||||
import {exclude, include} from "./build/vite/optimize"
|
||||
// 当前执行node命令时文件夹的地址(工作目录)
|
||||
const root = process.cwd()
|
||||
|
|
@ -34,7 +35,12 @@ export default ({command, mode}: ConfigEnv): UserConfig => {
|
|||
['/admin-api']: {
|
||||
target: apiProxyTarget,
|
||||
ws: false,
|
||||
changeOrigin: true
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
forwardOriginalHost(proxyReq, req)
|
||||
})
|
||||
}
|
||||
},
|
||||
// 开发环境下,直接代理 bucket 根路径,避免预签名 URL 因路径重写导致签名失效
|
||||
['/ncc-dev']: {
|
||||
|
|
@ -43,9 +49,7 @@ export default ({command, mode}: ConfigEnv): UserConfig => {
|
|||
changeOrigin: false,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
if (req.headers.host) {
|
||||
proxyReq.setHeader('host', req.headers.host)
|
||||
}
|
||||
forwardOriginalHost(proxyReq, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue