fix(review-tablet): 修复平板文件预览兼容性

pull/874/head
Codewoc 2026-04-07 10:49:44 +08:00
parent 25eef30456
commit 2179e7d9d2
13 changed files with 856 additions and 10 deletions

15
build/vite/proxy.ts Normal file
View File

@ -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)
}
}

View File

@ -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",

View File

@ -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: {}

View File

@ -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)
}

20
src/utils/urlParse.ts Normal file
View File

@ -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)
}

View File

@ -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>

View File

@ -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

View File

@ -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 || ''
}

View File

@ -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
}
})

View File

@ -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')
})

18
tests/url-parse.test.cjs Normal file
View File

@ -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
}
})

21
tests/vite-proxy.test.cjs Normal file
View File

@ -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']])
})

View File

@ -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)
})
}
}