主要阅览是简单的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


Logo

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

更多推荐