vue 阅览文档 原生+onlyoffice
主要阅览是简单的Image,mp4,mp3,pdf,doc,docx,ppt,pptx,xls,xlsx这几种文件
然后呢需要引入onlyoffice的插件和部署onlyoffice的服务 这个阅览感觉还是很高的继承的 开源 不用特别的api是不收费的 还是很不错的
文件可以单独找我要 球球1446714867
安装
前端插件: npm i “@onlyoffice/document-editor-vue”: “^1.6.1”
服务部署: onlyoffice文档
下边是一个简单里例子
再下边有README.md文件可以看
app.vue
<template>
<div class="container">
<div class="preview-file">
<PreviewFile :url="url" :type="type" :options="options" />
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
import PreviewFile from './previewFile/index.vue'
import type { PreviewFileState } from './previewFile/type'
const preview = reactive<PreviewFileState>({
type: 'audio',
url: 'xxxx.mp3',
options: { themeColor: '#000000' },
})
const { url, type, options } = toRefs(preview)
</script>
<style scoped>
.preview-file {
width: 800px;
height: 600px;
background: #fff;
}
</style>
previewFile
<template>
<div class="preview-file">
<div v-if="type === 'image'" class="preview-file-container preview-file-image">
<slot name="image">
<img ref="imageRef" class="preview-file-image-temp" :src="url" alt="图片异常,请检查图片地址"
@load="() => emit('imageLoad')" @error="(e: Event) => emit('imageError', e)" />
</slot>
</div>
<div v-if="type === 'video'" class="preview-file-container preview-file-video">
<slot name="video">
<video ref="videoRef" class="preview-file-video-temp" :src="url" controls
controlsList="nodownload noplaybackrate noremoteplayback nofullscreen" disablePictureInPicture
:autoplay="_VideoAutoplay" :muted="_VideoMuted" @canplay="() => emit('videoCanplay')"
@error="(e: Event) => emit('videoError', e)"></video>
</slot>
</div>
<div v-if="type === 'audio'" class="preview-file-container preview-file-audio">
<slot name="audio">
<AudioTemplate ref="audioTemplateRef" :url="url" :autoplay="_AudioAutoplay" :muted="_AudioMuted"
:theme-color="_AudioThemeColor" @canplay="() => emit('audioCanplay')"
@error="(e: Event) => emit('audioError', e)" />
</slot>
</div>
<div v-if="type === 'pdf'" class="preview-file-container preview-file-pdf">
<slot name="pdf">
<iframe class="preview-file-pdf-temp" :src="_PdfUrl" frameborder="0"></iframe>
</slot>
</div>
<div v-if="type === 'word'" class="preview-file-container preview-file-word">
<div class="preview-file-word-temp">
<slot name="word">
<DocumentEditor id="reviewDocEditor" :document-server-url="_onlyOfficeUrl"
:config="_OfficeEditorConfig" :events-on-error="onOnlyOfficeError" />
</slot>
</div>
</div>
<div v-else class="preview-file-container preview-file-default">
<slot name="default">
文件类型不支持
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useTemplateRef, watch, ref } from 'vue'
import AudioTemplate from './audio.vue'
import { DocumentEditor } from '@onlyoffice/document-editor-vue'
import type {
PreviewFileBindProps,
PreviewFileEmits,
Options,
VideoMethods,
VideoOptions,
ImageOptions,
AudioMethods,
AudioOptions,
PdfOptions,
WordOptions,
} from './type'
const imageEl = useTemplateRef('imageRef')
const videoEl = useTemplateRef('videoRef')
const audioEl = useTemplateRef<InstanceType<typeof AudioTemplate>>('audioTemplateRef')
const props = defineProps<PreviewFileBindProps>()
const emit = defineEmits<PreviewFileEmits>()
// -------- v-bind参数 --------
/** 默认宽度为100%,高度为100% */
const width = computed(() => (props?.options as Options)?.width || '100%')
const height = computed(() => (props?.options as Options)?.height || '100%')
/** 图片默认适应方式为contain */
const _ImageFit = computed(() => (props?.options as ImageOptions)?.fit || 'contain')
/** 视频默认不自动播放,静音 */
const _VideoAutoplay = computed(() => (props?.options as VideoOptions)?.autoplay || false)
const _VideoMuted = computed(() => (props?.options as VideoOptions)?.muted || true)
/** 音频默认不自动播放,静音 */
const _AudioAutoplay = computed(() => (props?.options as AudioOptions)?.autoplay || false)
const _AudioMuted = computed(() => (props?.options as AudioOptions)?.muted || true)
const _AudioThemeColor = computed(() => (props?.options as AudioOptions)?.themeColor || '#409eff')
/** pdf iframe 地址,根据 showToolbar 拼接展示参数 */
const _PdfUrl = computed(() => {
if (props.type !== 'pdf' || !props.url) return ''
const { showToolbar } = (props.options as PdfOptions) ?? {}
if (props.url.includes('#')) return props.url
return `${props.url}${showToolbar ? '' : '#toolbar=0&navpanes=0'}`
})
/** word */
const _onlyOfficeUrl = computed(() => (props?.options as WordOptions)?.onlyOfficeUrl || '')
const _WordType = computed(() => {
const { onlyType } = props?.options as WordOptions ?? {}
if (['ppt', 'pptx'].includes(onlyType)) {
return { fileType: onlyType, documentType: 'slide' }
}
if (['xls', 'xlsx'].includes(onlyType)) {
return { fileType: onlyType, documentType: 'cell' }
}
return { fileType: onlyType || 'docx', documentType: 'word' }
})
const _OfficeEditorConfig = computed(() => {
const { url, type } = props
if (type !== 'word') return
const { fileType, documentType } = _WordType.value
const title = url || `document.${fileType}`
return {
document: {
fileType,
key: url.replace(/[^0-9a-zA-Z\-._=]/g, '').slice(0, 128),
title,
url: url,
permissions: {
chat: false,
comment: false,
copy: false,
download: true,
edit: false,
fillForms: false,
modifyContentControl: false,
modifyFilter: false,
print: true,
review: false
}
},
documentType,
editorConfig: {
// 用 edit 模式 + edit:false 只读,比 view 模式兼容性更好
mode: 'edit',
lang: 'zh-CN',
user: {
id: 'review-guest',
name: '访客'
}
}
} as any
})
// 方法
/** 视频方法 */
const videoMethods: Record<VideoMethods, () => void> = {
play: () => {
videoEl.value && videoEl.value.play()
},
pause: () => {
videoEl.value && videoEl.value.pause()
},
}
/** 音频方法 */
const audioMethods: Record<AudioMethods, () => void> = {
play: () => {
audioEl.value?.play()
},
pause: () => {
audioEl.value?.pause()
},
}
/** onlyOffice 错误 */
const onOnlyOfficeError = (error: Event) => {
emit('onlyOfficeError', error)
}
/** 复制 url */
const copyUrl = () => {
navigator.clipboard.writeText(props.url)
}
const EXPOSES = {
videoMethods,
audioMethods,
copyUrl: copyUrl,
imageEl,
videoEl,
audioEl,
}
defineExpose(EXPOSES)
</script>
<style scoped lang="scss">
.preview-file {
width: v-bind(width);
height: v-bind(height);
overflow: hidden;
user-select: none;
&-container {
width: 100%;
height: 100%;
display: flex;
overflow: auto;
}
&-image {
justify-content: center;
align-items: center;
.preview-file-image-temp {
width: 100%;
object-fit: v-bind(_ImageFit);
}
}
&-video {
justify-content: center;
align-items: center;
.preview-file-video-temp {
width: 100%;
height: 100%;
&::-webkit-media-controls-overflow-button {
display: none;
}
&::-webkit-media-controls-fullscreen-button {
display: none;
}
}
}
&-audio {
justify-content: center;
align-items: center;
overflow: hidden;
}
&-pdf {
width: 100%;
height: 100%;
.preview-file-pdf-temp {
width: 100%;
height: 100%;
}
}
&-word {
width: 100%;
height: 100%;
overflow: hidden;
&-temp {
width: calc(100% + 40px);
height: calc(100% + 34px);
margin: -34px 0 0 -40px;
}
}
&-default {
justify-content: center;
align-items: center;
font-size: 18px;
color: #999;
}
}
</style>
audio
<template>
<div class="preview-file-audio-wrapper" :style="themeVars">
<canvas ref="canvasRef" class="preview-file-audio-canvas"></canvas>
<div class="preview-file-audio-toolbar">
<button type="button" class="ctrl-btn play-btn" :title="isPlaying ? '暂停' : '播放'" @click="togglePlay">
<svg v-if="!isPlaying" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor">
<path d="M6 5h4v14H6zm8 0h4v14h-4z" />
</svg>
</button>
<div class="progress-panel">
<span class="time-text">{{ formatTime(currentTime) }}</span>
<div class="slider-track">
<input type="range" class="progress-slider" min="0" max="100" step="0.1" :value="progressPercent"
:style="{ '--progress': progressPercent + '%' }" @mousedown="isSeeking = true"
@touchstart="isSeeking = true" @input="onProgressInput" @change="onProgressChange" />
</div>
<span class="time-text">{{ formatTime(duration) }}</span>
</div>
<div class="volume-panel">
<button type="button" class="ctrl-btn volume-btn" :title="isMuted ? '取消静音' : '静音'"
@click="toggleMute">
<svg v-if="isMuted || volume === 0" viewBox="0 0 24 24" fill="currentColor">
<path
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
<svg v-else-if="volume < 0.5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
</button>
<div class="slider-track">
<input type="range" class="volume-slider" min="0" max="100" :value="volumePercent"
:style="{ '--volume': volumePercent + '%' }" @input="onVolumeInput" />
</div>
</div>
</div>
<audio ref="audioRef" class="preview-file-audio-hidden" :src="url" :autoplay="autoplay" preload="metadata"
crossorigin="anonymous" @canplay="onCanplay" @error="onError" @play="onPlay" @pause="onPause"
@ended="onEnded" @timeupdate="onTimeUpdate" @loadedmetadata="onLoadedMetadata"></audio>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import type { AudioEmits, AudioProps, AudioTheme } from './type'
const DEFAULT_AUDIO_PRIMARY = ''
const hexToRgb = (hex: string) => {
const h = hex.replace('#', '')
const normalized = h.length === 3
? h.split('').map((c) => c + c).join('')
: h
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
}
}
const rgbToHex = (r: number, g: number, b: number) => {
const toHex = (n: number) => Math.round(n).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
const mixWithWhite = (hex: string, whiteWeight: number) => {
const { r, g, b } = hexToRgb(hex)
const w = Math.min(1, Math.max(0, whiteWeight))
return rgbToHex(
r + (255 - r) * w,
g + (255 - g) * w,
b + (255 - b) * w,
)
}
const lighten = (hex: string, amount: number) => mixWithWhite(hex, amount)
const rgba = (hex: string, alpha: number) => {
const { r, g, b } = hexToRgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const buildAudioTheme = (primary: string): AudioTheme => ({
primary,
primaryLight: lighten(primary, 0.22),
spectrumBottom: 'rgba(255, 255, 255, 0.95)',
spectrumMid: rgba(primary, 0.5),
spectrumTop: rgba(primary, 0.78),
line: rgba(primary, 0.45),
lineGlow: rgba(primary, 0.55),
track: primary,
trackBg: mixWithWhite(primary, 0.86),
volumeTrackBg: mixWithWhite(primary, 0.82),
panelBg: mixWithWhite(primary, 0.92),
panelBorder: mixWithWhite(primary, 0.78),
textMuted: '#8a96ad',
iconMuted: '#6b7a99',
iconHoverBg: mixWithWhite(primary, 0.88),
btnShadow: rgba(primary, 0.32),
btnShadowHover: rgba(primary, 0.42),
thumbShadow: rgba(primary, 0.32),
})
const props = withDefaults(defineProps<AudioProps>(), {
autoplay: false,
muted: true,
themeColor: DEFAULT_AUDIO_PRIMARY,
})
const theme = computed(() => buildAudioTheme(props.themeColor))
const themeVars = computed(() => ({
'--audio-primary': theme.value.primary,
'--audio-primary-light': theme.value.primaryLight,
'--audio-track': theme.value.track,
'--audio-track-bg': theme.value.trackBg,
'--audio-volume-track-bg': theme.value.volumeTrackBg,
'--audio-panel-bg': theme.value.panelBg,
'--audio-panel-border': theme.value.panelBorder,
'--audio-text-muted': theme.value.textMuted,
'--audio-icon-muted': theme.value.iconMuted,
'--audio-icon-hover-bg': theme.value.iconHoverBg,
'--audio-btn-shadow': theme.value.btnShadow,
'--audio-btn-shadow-hover': theme.value.btnShadowHover,
'--audio-thumb-shadow': theme.value.thumbShadow,
}))
const emit = defineEmits<AudioEmits>()
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
const audioRef = useTemplateRef<HTMLAudioElement>('audioRef')
const isPlaying = ref(false)
const isMuted = ref(props.muted)
const isSeeking = ref(false)
const volume = ref(0.8)
const currentTime = ref(0)
const duration = ref(0)
const volumePercent = computed(() => Math.round(volume.value * 100))
const progressPercent = computed(() => {
if (!duration.value) return 0
return (currentTime.value / duration.value) * 100
})
const formatTime = (seconds: number) => {
if (!seconds || !isFinite(seconds)) return '00:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let gainNode: GainNode | null = null
let sourceNode: MediaElementAudioSourceNode | null = null
let animationId = 0
let resizeObserver: ResizeObserver | null = null
let freqDataArray: Uint8Array | null = null
let smoothedValues: number[] | null = null
const getBarCount = (w: number) => Math.min(120, Math.max(64, Math.floor(w / 3)))
const getSpectrumValues = (freqData: Uint8Array, barCount: number) => {
const values: number[] = []
const minBin = 2
const maxBin = freqData.length - 1
const logMin = Math.log(minBin)
const logMax = Math.log(maxBin)
for (let i = 0; i < barCount; i++) {
const start = Math.min(
maxBin,
Math.floor(Math.exp(logMin + (logMax - logMin) * (i / barCount))),
)
const end = Math.min(
maxBin,
Math.floor(Math.exp(logMin + (logMax - logMin) * ((i + 1) / barCount))),
)
let sum = 0
let count = 0
for (let j = start; j <= end; j++) {
sum += freqData[j]
count++
}
values.push(count ? sum / count / 255 : 0)
}
const peak = Math.max(...values, 0.1)
return values.map((v, i) => {
const bassAttenuation = 0.45 + 0.55 * (i / Math.max(barCount - 1, 1))
return Math.pow((v / peak) * bassAttenuation, 0.72)
})
}
const smoothAdjacent = (values: number[]) => {
const next = [...values]
for (let i = 0; i < values.length; i++) {
const prev = values[Math.max(0, i - 1)]
const curr = values[i]
const following = values[Math.min(values.length - 1, i + 1)]
next[i] = prev * 0.18 + curr * 0.64 + following * 0.18
}
return next
}
const interpolateFrame = (target: number[]) => {
if (!smoothedValues || smoothedValues.length !== target.length) {
smoothedValues = [...target]
return smoothedValues
}
for (let i = 0; i < target.length; i++) {
smoothedValues[i] = smoothedValues[i] * 0.72 + target[i] * 0.28
}
return smoothedValues
}
const createBarGradient = (
ctx: CanvasRenderingContext2D,
x: number,
baselineY: number,
topY: number,
theme: AudioTheme,
) => {
const gradient = ctx.createLinearGradient(x, baselineY, x, topY)
gradient.addColorStop(0, theme.spectrumBottom)
gradient.addColorStop(0.4, theme.spectrumMid)
gradient.addColorStop(1, theme.spectrumTop)
return gradient
}
const drawSpectrumVisualizer = (
ctx: CanvasRenderingContext2D,
w: number,
h: number,
values: number[],
theme: AudioTheme,
idle = false,
) => {
const chartStartX = 0
const chartWidth = w
const baselineY = h - 6
const topPadding = h * 0.05
const gap = 3
const barCount = values.length
const barWidth = Math.max(2, (chartWidth - gap * (barCount - 1)) / barCount)
const maxBarHeight = baselineY - topPadding
ctx.save()
if (idle) {
ctx.beginPath()
ctx.moveTo(chartStartX, baselineY)
ctx.lineTo(chartStartX + chartWidth, baselineY)
ctx.strokeStyle = theme.line
ctx.shadowColor = theme.lineGlow
ctx.shadowBlur = 8
ctx.lineWidth = 1
ctx.stroke()
}
if (!idle) {
ctx.shadowBlur = 0
for (let i = 0; i < barCount; i++) {
const displayValue = Math.min(1, Math.max(0.05, values[i]) * 1.12)
const barHeight = Math.min(maxBarHeight, Math.max(4, displayValue * maxBarHeight))
const x = chartStartX + i * (barWidth + gap)
const y = baselineY - barHeight
const topRadius = Math.min(barWidth / 2, 3)
ctx.fillStyle = createBarGradient(ctx, x, baselineY, y, theme)
ctx.beginPath()
ctx.roundRect(x, y, barWidth, barHeight, [topRadius, topRadius, 0, 0])
ctx.fill()
}
}
ctx.restore()
}
const drawIdle = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const w = canvas.width / dpr
const h = canvas.height / dpr
ctx.clearRect(0, 0, w, h)
const barCount = getBarCount(w)
drawSpectrumVisualizer(ctx, w, h, new Array(barCount).fill(0), theme.value, true)
}
const draw = () => {
const canvas = canvasRef.value
if (!canvas || !analyser || !freqDataArray) return
const ctx = canvas.getContext('2d')
if (!ctx) return
analyser.getByteFrequencyData(freqDataArray as Uint8Array<ArrayBuffer>)
const dpr = window.devicePixelRatio || 1
const w = canvas.width / dpr
const h = canvas.height / dpr
ctx.clearRect(0, 0, w, h)
const barCount = getBarCount(w)
const raw = getSpectrumValues(freqDataArray, barCount)
const values = interpolateFrame(smoothAdjacent(raw))
drawSpectrumVisualizer(ctx, w, h, values, theme.value)
animationId = requestAnimationFrame(draw)
}
const applyVolume = () => {
if (!gainNode) return
gainNode.gain.value = isMuted.value ? 0 : volume.value
}
const initAudioContext = () => {
if (!audioRef.value || audioContext) return
audioContext = new AudioContext()
analyser = audioContext.createAnalyser()
analyser.fftSize = 512
analyser.minDecibels = -90
analyser.maxDecibels = -10
analyser.smoothingTimeConstant = 0.85
gainNode = audioContext.createGain()
sourceNode = audioContext.createMediaElementSource(audioRef.value)
// 分析器放在增益之前,静音时仍能拿到完整频谱数据
sourceNode.connect(analyser)
analyser.connect(gainNode)
gainNode.connect(audioContext.destination)
freqDataArray = new Uint8Array(analyser.frequencyBinCount)
applyVolume()
}
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const { clientWidth, clientHeight } = canvas
const dpr = window.devicePixelRatio || 1
canvas.width = clientWidth * dpr
canvas.height = clientHeight * dpr
const ctx = canvas.getContext('2d')
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
const startVisualization = async () => {
initAudioContext()
if (audioContext?.state === 'suspended') {
await audioContext.resume()
}
resizeCanvas()
cancelAnimationFrame(animationId)
draw()
}
const stopVisualization = () => {
cancelAnimationFrame(animationId)
animationId = 0
smoothedValues = null
resizeCanvas()
drawIdle()
}
const togglePlay = async () => {
if (!audioRef.value) return
if (isPlaying.value) {
audioRef.value.pause()
return
}
initAudioContext()
if (audioContext?.state === 'suspended') {
await audioContext.resume()
}
await audioRef.value.play()
}
const toggleMute = () => {
isMuted.value = !isMuted.value
applyVolume()
}
const onVolumeInput = (e: Event) => {
const val = Number((e.target as HTMLInputElement).value) / 100
volume.value = val
if (val > 0) isMuted.value = false
applyVolume()
}
const onTimeUpdate = () => {
if (!audioRef.value || isSeeking.value) return
currentTime.value = audioRef.value.currentTime
}
const onLoadedMetadata = () => {
if (!audioRef.value) return
duration.value = audioRef.value.duration || 0
}
const onProgressInput = (e: Event) => {
const percent = Number((e.target as HTMLInputElement).value)
if (!duration.value) return
currentTime.value = (percent / 100) * duration.value
}
const onProgressChange = (e: Event) => {
const percent = Number((e.target as HTMLInputElement).value)
if (!audioRef.value || !duration.value) return
const time = (percent / 100) * duration.value
audioRef.value.currentTime = time
currentTime.value = time
isSeeking.value = false
}
const onCanplay = () => {
resizeCanvas()
drawIdle()
emit('canplay')
}
const onError = (e: Event) => {
emit('error', e)
}
const onPlay = () => {
isPlaying.value = true
startVisualization()
}
const onPause = () => {
isPlaying.value = false
stopVisualization()
}
const onEnded = () => {
isPlaying.value = false
currentTime.value = 0
stopVisualization()
}
const play = async () => {
if (!audioRef.value) return
initAudioContext()
if (audioContext?.state === 'suspended') {
await audioContext.resume()
}
await audioRef.value.play()
}
const pause = () => {
audioRef.value?.pause()
}
watch(() => props.muted, (val) => {
isMuted.value = val
applyVolume()
})
watch(() => props.themeColor, () => {
resizeCanvas()
if (isPlaying.value) return
drawIdle()
}, { deep: true })
watch(() => props.url, () => {
currentTime.value = 0
duration.value = 0
isPlaying.value = false
smoothedValues = null
stopVisualization()
})
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
resizeObserver = new ResizeObserver(() => {
resizeCanvas()
if (isPlaying.value) return
drawIdle()
})
resizeObserver.observe(canvas)
resizeCanvas()
drawIdle()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
stopVisualization()
sourceNode?.disconnect()
gainNode?.disconnect()
analyser?.disconnect()
audioContext?.close()
audioContext = null
analyser = null
gainNode = null
sourceNode = null
freqDataArray = null
smoothedValues = null
})
defineExpose({
audioEl: audioRef,
play,
pause,
})
</script>
<style scoped lang="scss">
.preview-file-audio-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 0;
padding: 16px 20px 20px;
box-sizing: border-box;
pointer-events: all;
}
.preview-file-audio-canvas {
flex: 1;
width: 100%;
min-height: 0;
background: transparent;
display: block;
}
.preview-file-audio-toolbar {
flex-shrink: 0;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding-top: 12px;
}
.time-text {
flex-shrink: 0;
width: 36px;
font-size: 11px;
color: var(--audio-text-muted);
font-variant-numeric: tabular-nums;
text-align: center;
user-select: none;
}
.progress-panel {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 10px;
background: var(--audio-panel-bg);
border: 1px solid var(--audio-panel-border);
border-radius: 10px;
overflow: hidden;
}
.slider-track {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 0 6px;
}
.progress-slider {
width: 100%;
min-width: 0;
height: 3px;
appearance: none;
background: linear-gradient(
to right,
var(--audio-track) var(--progress, 0%),
var(--audio-track-bg) var(--progress, 0%)
);
border-radius: 99px;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--audio-primary);
box-shadow: 0 1px 3px var(--audio-thumb-shadow);
cursor: pointer;
transition: transform 0.15s;
}
&:hover::-webkit-slider-thumb {
transform: scale(1.1);
}
&::-moz-range-thumb {
width: 12px;
height: 12px;
border: 2px solid var(--audio-primary);
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px var(--audio-thumb-shadow);
cursor: pointer;
}
&::-moz-range-track {
height: 3px;
background: var(--audio-track-bg);
border-radius: 99px;
}
&::-moz-range-progress {
height: 3px;
background: var(--audio-track);
border-radius: 99px;
}
}
.ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
&:active {
transform: scale(0.94);
}
svg {
display: block;
}
}
.play-btn {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
background: linear-gradient(145deg, var(--audio-primary-light) 0%, var(--audio-primary) 100%);
color: #fff;
box-shadow: 0 2px 10px var(--audio-btn-shadow);
svg {
width: 20px;
height: 20px;
}
&:hover {
box-shadow: 0 4px 14px var(--audio-btn-shadow-hover);
}
}
.volume-panel {
flex-shrink: 0;
width: 128px;
display: flex;
align-items: center;
gap: 4px;
height: 40px;
padding: 0 8px;
background: var(--audio-panel-bg);
border: 1px solid var(--audio-panel-border);
border-radius: 10px;
overflow: hidden;
}
.volume-btn {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 6px;
background: transparent;
color: var(--audio-icon-muted);
svg {
width: 16px;
height: 16px;
}
&:hover {
background: var(--audio-icon-hover-bg);
color: var(--audio-primary);
}
}
.volume-slider {
width: 100%;
min-width: 0;
height: 3px;
appearance: none;
background: linear-gradient(
to right,
var(--audio-track) var(--volume, 0%),
var(--audio-volume-track-bg) var(--volume, 0%)
);
border-radius: 99px;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--audio-primary);
box-shadow: 0 1px 3px var(--audio-thumb-shadow);
cursor: pointer;
transition: transform 0.15s;
}
&:hover::-webkit-slider-thumb {
transform: scale(1.1);
}
&::-moz-range-thumb {
width: 12px;
height: 12px;
border: 2px solid var(--audio-primary);
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px var(--audio-thumb-shadow);
cursor: pointer;
}
&::-moz-range-track {
height: 3px;
background: var(--audio-volume-track-bg);
border-radius: 99px;
}
&::-moz-range-progress {
height: 3px;
background: var(--audio-track);
border-radius: 99px;
}
}
.preview-file-audio-hidden {
display: none;
}
</style>
README.md
# PreviewFile 文件预览组件
统一预览图片、视频、音频、PDF、Office 文档(Word / PPT / Excel)的 Vue 3 组件。
预览ppt | pptx | doc | docx | xls | xlsx需要安装插件 与 部署 onlyOffice服务
前端插件: npm i "@onlyoffice/document-editor-vue": "^1.6.1"
服务部署: <a href="https://api.onlyoffice.com/zh-CN/docs/docs-api/get-started/basic-concepts/">onlyoffice文档</a>
## 快速开始
```vue
<template>
<PreviewFile :url="url" :type="type" :options="options" />
</template>
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
import PreviewFile from './previewFile/index.vue'
import type { PreviewFileState } from './previewFile/type'
const preview = reactive<PreviewFileState>({
type: 'image',
url: 'https://example.com/photo.jpg',
options: { fit: 'contain' },
})
const { url, type, options } = toRefs(preview)
</script>
Props
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
'image' | 'video' | 'audio' | 'pdf' | 'word' |
是 | 预览类型 |
url |
string |
是 | 文件地址(远程 URL 或 blob: URL) |
options |
见下方各类型说明 | 否(word 必填) |
类型相关配置 |
组件容器默认宽高为 100%,可通过 options.width / options.height 覆盖。
各 type 说明与 options
公共 options(所有类型可选)
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
width |
string |
'100%' |
组件宽度 |
height |
string |
'100%' |
组件高度 |
image — 图片
使用 <img> 渲染,直接传入图片 URL。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
fit |
'contain' | 'cover' |
'contain' |
object-fit 适应方式 |
<PreviewFile
type="image"
url="https://example.com/photo.jpg"
:options="{ fit: 'contain', width: '800px', height: '600px' }"
@imageLoad="onLoad"
@imageError="onError"
/>
video — 视频
使用原生 <video> 播放,已禁用下载、倍速、远程播放、画中画、全屏等控件。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
autoplay |
boolean |
false |
是否自动播放 |
muted |
boolean |
true |
是否静音(自动播放时建议保持 true) |
<PreviewFile
type="video"
url="https://example.com/video.mp4"
:options="{ autoplay: false, muted: true }"
@videoCanplay="onCanplay"
@videoError="onError"
/>
通过 ref 可调用 videoMethods.play() / videoMethods.pause()。
audio — 音频
自定义播放器,基于 Web Audio API 实现频谱可视化,并提供进度条、音量控制。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
autoplay |
boolean |
false |
是否自动播放 |
muted |
boolean |
true |
是否静音 |
themeColor |
string |
'#409eff' |
主题色(控制按钮、频谱、进度条等) |
themeColor 会自动推导出一组 CSS 变量(主色、浅色、轨道、面板背景等),用于播放器 UI 与频谱渐变。
<PreviewFile
type="audio"
url="https://example.com/music.mp3"
:options="{ themeColor: '#67c23a' }"
@audioCanplay="onCanplay"
@audioError="onError"
/>
通过 ref 可调用 audioMethods.play() / audioMethods.pause()。
pdf — PDF
使用 <iframe> 内嵌浏览器 PDF 查看器。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
showToolbar |
boolean |
undefined(显示工具栏) |
设为 false 时隐藏工具栏和导航面板 |
<PreviewFile
type="pdf"
:url="pdfBlobUrl"
:options="{ showToolbar: false }"
/>
PDF 必须使用 Blob URL
OSS 等存储返回的 PDF 常带 Content-Disposition: attachment 响应头,浏览器会直接触发下载而非内嵌预览。需要先用 fetch 拿到 Blob,再 URL.createObjectURL 转成 blob: 地址传给组件。
项目内已提供工具函数 fetchBlobUrl:
import { onBeforeUnmount, ref } from 'vue'
import { fetchBlobUrl } from './previewFile/utils'
const pdfBlobUrl = ref('')
// 加载 PDF
pdfBlobUrl.value = await fetchBlobUrl(
'https://your-oss.com/file.pdf',
'/api' // 本地开发时代理前缀,见下方「工具函数」
)
// 组件卸载时释放内存
onBeforeUnmount(() => {
if (pdfBlobUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(pdfBlobUrl.value)
}
})
也可手动处理:
const response = await fetch(fileUrl)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
// 将 blobUrl 传给 PreviewFile 的 url
showToolbar: false时,组件会自动在 URL 后拼接#toolbar=0&navpanes=0(若 URL 已含#则不再拼接)。
word — Office 文档(OnlyOffice)
通过 OnlyOffice Document Editor 只读预览 Word / PPT / Excel。编辑器以 mode: 'edit' 加载,并通过 permissions.edit: false 等权限限制为只读,兼容性优于纯 view 模式。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
onlyOfficeUrl |
string |
是 | OnlyOffice Document Server 地址 |
onlyType |
'doc' | 'docx' | 'ppt' | 'pptx' | 'xls' | 'xlsx' |
是 | 文件类型,决定编辑器模式 |
onlyType 与 OnlyOffice documentType 映射:
| onlyType | documentType | 说明 |
|---|---|---|
doc / docx |
word |
Word 文档 |
ppt / pptx |
slide |
演示文稿 |
xls / xlsx |
cell |
电子表格 |
<PreviewFile
type="word"
url="https://example.com/demo.pptx"
:options="{
onlyOfficeUrl: 'http://your-onlyoffice-server:5006',
onlyType: 'pptx',
width: '100%',
height: '600px',
}"
@onlyOfficeError="onOnlyOfficeError"
/>
url必须是 OnlyOffice 服务端能访问到的文件地址(公网或内网可达)。
插槽(Slots)
每种预览类型均提供具名插槽,可在保留外层容器样式的前提下自定义内部渲染内容。未提供插槽时使用内置默认模板。
| 插槽名 | 说明 |
|---|---|
image |
自定义图片预览区域 |
video |
自定义视频播放器 |
audio |
自定义音频播放器 |
pdf |
自定义 PDF iframe |
word |
自定义 OnlyOffice 编辑器 |
default |
不支持的 type 时的占位内容(默认文案:「文件类型不支持」) |
<PreviewFile type="image" :url="imageUrl">
<template #image>
<img :src="imageUrl" alt="自定义图片" style="max-width: 100%" />
</template>
</PreviewFile>
事件
| 事件名 | 触发时机 | 参数 |
|---|---|---|
imageLoad |
图片加载完成 | — |
imageError |
图片加载失败 | Event |
videoCanplay |
视频可播放 | — |
videoError |
视频加载失败 | Event |
audioCanplay |
音频可播放 | — |
audioError |
音频加载失败 | Event |
onlyOfficeError |
OnlyOffice 编辑器报错 | Event |
模板中可使用 camelCase(@imageLoad)或 kebab-case(@image-load),二者等价。
暴露方法(defineExpose)
通过 ref 获取组件实例后可调用:
| 名称 | 说明 |
|---|---|
videoMethods.play() |
播放视频 |
videoMethods.pause() |
暂停视频 |
audioMethods.play() |
播放音频 |
audioMethods.pause() |
暂停音频 |
copyUrl() |
复制当前 url 到剪贴板 |
imageEl |
图片 DOM 引用 |
videoEl |
视频 DOM 引用 |
audioEl |
音频子组件(audio.vue)实例引用 |
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const previewRef = useTemplateRef('previewRef')
const play = () => previewRef.value?.videoMethods.play()
</script>
<template>
<PreviewFile ref="previewRef" type="video" :url="videoUrl" />
</template>
TypeScript 类型
type 要能切换五种类型,options 只提示当前 type 对应的字段——父组件用 { url, type, options } 判别联合,这是 TypeScript 唯一能联动校验的写法。
推荐写法:reactive + toRefs
状态用 PreviewFileState 对象存(type 与 options 联动),模板仍分开传参:
<template>
<PreviewFile :url="url" :type="type" :options="options" />
</template>
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
import PreviewFile from './previewFile/index.vue'
import type { PreviewFileState } from './previewFile/type'
import { setPreviewFile } from './previewFile/type'
const preview = reactive<PreviewFileState>({
type: 'audio',
url: 'https://example.com/music.mp3',
options: { themeColor: '#000000' }, // ✅ 只校验 AudioOptions
})
const { url, type, options } = toRefs(preview)
// 切换:通过 preview 整体赋值,type 与 options 严格联动
setPreviewFile(preview, {
type: 'video',
url: 'https://example.com/video.mp4',
options: { autoplay: true, muted: false }, // ✅ VideoOptions
})
setPreviewFile(preview, {
type: 'pdf',
url: pdfBlobUrl,
options: { showToolbar: false }, // ✅ PdfOptions
})
setPreviewFile(preview, {
type: 'word',
url: 'https://example.com/demo.ppt',
options: {
onlyOfficeUrl: 'http://10.45.164.9:5006',
onlyType: 'ppt',
}, // ✅ WordOptions
})
// ❌ type 为 word 时 options 不能写 themeColor
setPreviewFile(preview, {
type: 'word',
url: '...',
options: { themeColor: '#000' },
})
</script>
原理: PreviewFileState 是判别联合,type: 'video' 时整包对象的 options 只能是 VideoOptions,写 themeColor 会报错。
修改 options 时,通过 preview 判断 type 收窄:
if (preview.type === 'audio') {
preview.options // AudioOptions | undefined
}
各 type 对应的 options 类型
| type | options 类型 | 字段 |
|---|---|---|
image |
ImageOptions |
fit?, width?, height? |
video |
VideoOptions |
autoplay?, muted?, width?, height? |
audio |
AudioOptions |
autoplay?, muted?, themeColor?, width?, height? |
pdf |
PdfOptions |
showToolbar?, width?, height? |
word |
WordOptions(必填) |
onlyOfficeUrl, onlyType, width?, height? |
为什么不建议三个独立 ref
url / type / options 各写一个 ref() 时,TypeScript 无法根据 type 收窄 options。请用 reactive<PreviewFileState> 存状态,再 toRefs 拆开给模板。
常用类型导入
import type {
PreviewFileState,
PreviewFileType,
PreviewFileStateByType,
PreviewFileBindProps,
PreviewFileEmits,
ImageOptions,
VideoOptions,
AudioOptions,
PdfOptions,
WordOptions,
AudioProps,
AudioTheme,
} from './type'
import { setPreviewFile } from './type'
文件结构
previewFile/
├── index.vue # 主组件入口
├── audio.vue # 音频播放器子组件(频谱可视化 + 控制栏)
├── type.ts # TypeScript 类型定义
├── utils.ts # 代理 / Blob URL 工具
└── README.md
依赖
| 依赖 | 用途 |
|---|---|
vue ^3.5 |
组件框架 |
@onlyoffice/document-editor-vue |
word 类型 Office 文档预览 |
sass |
组件样式(scoped SCSS) |
word 类型还需单独部署 OnlyOffice Document Server,并将服务地址传入 options.onlyOfficeUrl。
更多推荐

所有评论(0)