主要阅览是简单的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'是预览类型
urlstring是文件地址(远程 URL 或 blob: URL)
options见下方各类型说明否(word 必填)类型相关配置

组件容器默认宽高为 100%,可通过 options.width / options.height 覆盖。


各 type 说明与 options

公共 options(所有类型可选)

字段类型默认值说明
widthstring'100%'组件宽度
heightstring'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> 播放,已禁用下载、倍速、远程播放、画中画、全屏等控件。

字段类型默认值说明
autoplaybooleanfalse是否自动播放
mutedbooleantrue是否静音(自动播放时建议保持 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 实现频谱可视化,并提供进度条、音量控制。

字段类型默认值说明
autoplaybooleanfalse是否自动播放
mutedbooleantrue是否静音
themeColorstring'#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 查看器。

字段类型默认值说明
showToolbarbooleanundefined(显示工具栏)设为 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 模式。

字段类型必填说明
onlyOfficeUrlstring是OnlyOffice Document Server 地址
onlyType'doc' | 'docx' | 'ppt' | 'pptx' | 'xls' | 'xlsx'是文件类型,决定编辑器模式

onlyType 与 OnlyOffice documentType 映射:

onlyTypedocumentType说明
doc / docxwordWord 文档
ppt / pptxslide演示文稿
xls / xlsxcell电子表格
<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
onlyOfficeErrorOnlyOffice 编辑器报错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 类型

typeoptions 类型字段
imageImageOptionsfit?, width?, height?
videoVideoOptionsautoplay?, muted?, width?, height?
audioAudioOptionsautoplay?, muted?, themeColor?, width?, height?
pdfPdfOptionsshowToolbar?, width?, height?
wordWordOptions(必填)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-vueword 类型 Office 文档预览
sass组件样式(scoped SCSS)

word 类型还需单独部署 OnlyOffice Document Server,并将服务地址传入 options.onlyOfficeUrl。


Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐