使用vue实现文件预览服务

说明

文件预览一直是前端很麻烦但是又必须要实现的一个功能,我根据工作中的经验,整合了常见类型文件的预览功能实现,并整合在一起

环境

json 复制代码
"dependencies": {
    "@google/model-viewer": "^4.0.0",
    "@videojs-player/vue": "^1.0.0",
    "axios": "^1.8.4",
    "babylonjs": "^7.54.0",
    "babylonjs-loaders": "^7.54.0",
    "cesium": "^1.127.0",
    "dxf-viewer": "^1.0.42",
    "file-type": "^20.4.1",
    "mavon-editor": "^3.0.2",
    "naive-ui": "^2.41.0",
    "pangju-utils": "^2.3.2",
    "three": "^0.169.0",
    "video.js": "^7.21.6",
    "vue": "^3.5.13",
    "vue-json-viewer": "^3.0.4",
    "vue-router": "^4.5.0"
}
  • @google/model-viewer 谷歌模型预览器(只支持glb
  • @videojs-player/vue 视频播放器(videojs vue3实现)
  • babylonjs 3d模型库(微软出品的)
  • babylonjs-loaders 模型加载器(用于支持各种模型文件类型)
  • cesium 3维和2维库(主要用来加载kml)
  • dxf-viewer dxf预览器(dwg的兄弟类型)
  • file-type 文件类型解析器(解析文件mime-type)
  • mavon-editor markdown编辑器(用于md文件预览)
  • naive-ui vue3 ui 库(我个人喜欢的一个ui库)
  • pangju-utils 自己写的工具库,没写文档(目前是我自己用的,后边有空会完善文档)
  • axios axios(http请求库)
  • three threejs(@google/model-viewer、dxf-viewer 依赖)
  • video.js videojs(@videojs-player/vue 依赖)
  • vue-json-viewer json文件查看器

代码仓库

实现方法

音频(Audio)

支持类型

  • audio/mpeg(mp3)
  • audio/wav(wav)
  • audio/ogg(ogg)
  • audio/x-aac(acc)
  • audio/x-flac(flac)

实现原理

使用原生audio标签实现

html 复制代码
<script setup>
defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

defineEmits(["finished"]);
</script>

<template>
    <div class="audio-viewer">
        <audio
            controls
            autoplay
            :src="fileUrl"
            @loadeddata="$emit('finished')"
        ></audio>
    </div>
</template>

<style scoped lang="less">
.audio-viewer {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;
}
</style>

视频(Video)

支持类型

  • video/mp4(mp4)
  • video/ogg(ogg)
  • video/webm(webm)

实现原理

使用VideoPlayer组件实现

html 复制代码
<script setup>
import { VideoPlayer } from "@videojs-player/vue";
import videojs from "video.js";
import "video.js/dist/video-js.css";

videojs.addLanguage("zh-CN", {
    Play: "播放",
    Pause: "暂停",
    "Current Time": "当前时间",
    Duration: "时长",
    "Remaining Time": "剩余时间",
    "Stream Type": "媒体流类型",
    LIVE: "直播",
    Loaded: "加载完成",
    Progress: "进度",
    Fullscreen: "全屏",
    "Non-Fullscreen": "退出全屏",
    "Picture-in-Picture": "画中画",
    "Exit Picture-in-Picture": "退出画中画",
    Mute: "静音",
    Unmute: "开启音效",
    "Playback Rate": "播放速度",
    Subtitles: "字幕",
    "subtitles off": "关闭字幕",
    Captions: "内嵌字幕",
    "captions off": "关闭内嵌字幕",
    Chapters: "节目段落",
    "Close Modal Dialog": "关闭弹窗",
    Descriptions: "描述",
    "descriptions off": "关闭描述",
    "Audio Track": "音轨",
    "You aborted the media playback": "视频播放被终止",
    "A network error caused the media download to fail part-way.":
        "网络错误导致视频下载中途失败。",
    "The media could not be loaded, either because the server or network failed or because the format is not supported.":
        "视频因格式不支持或者服务器或网络的问题无法加载。",
    "The media playback was aborted due to a corruption problem or because the media used features your browser did not support.":
        "由于视频文件损坏或是该视频使用了你的浏览器不支持的功能,播放终止。",
    "No compatible source was found for this media.":
        "无法找到此视频兼容的源。",
    "The media is encrypted and we do not have the keys to decrypt it.":
        "视频已加密,无法解密。",
    "Play Video": "播放视频",
    Close: "关闭",
    "Modal Window": "弹窗",
    "This is a modal window": "这是一个弹窗",
    "This modal can be closed by pressing the Escape key or activating the close button.":
        "可以按ESC按键或启用关闭按钮来关闭此弹窗。",
    ", opens captions settings dialog": ", 开启标题设置弹窗",
    ", opens subtitles settings dialog": ", 开启字幕设置弹窗",
    ", opens descriptions settings dialog": ", 开启描述设置弹窗",
    ", selected": ", 选择",
    "captions settings": "字幕设定",
    "Audio Player": "音频播放器",
    "Video Player": "视频播放器",
    Replay: "重新播放",
    "Progress Bar": "进度条",
    "Volume Level": "音量",
    "subtitles settings": "字幕设定",
    "descriptions settings": "描述设定",
    Text: "文字",
    White: "白",
    Black: "黑",
    Red: "红",
    Green: "绿",
    Blue: "蓝",
    Yellow: "黄",
    Magenta: "紫红",
    Cyan: "青",
    Background: "背景",
    Window: "窗口",
    Transparent: "透明",
    "Semi-Transparent": "半透明",
    Opaque: "不透明",
    "Font Size": "字体尺寸",
    "Text Edge Style": "字体边缘样式",
    None: "无",
    Raised: "浮雕",
    Depressed: "压低",
    Uniform: "均匀",
    Dropshadow: "下阴影",
    "Font Family": "字体库",
    "Proportional Sans-Serif": "比例无细体",
    "Monospace Sans-Serif": "单间隔无细体",
    "Proportional Serif": "比例细体",
    "Monospace Serif": "单间隔细体",
    Casual: "舒适",
    Script: "手写体",
    "Small Caps": "小型大写字体",
    Reset: "重置",
    "restore all settings to the default values": "恢复全部设定至预设值",
    Done: "完成",
    "Caption Settings Dialog": "字幕设定窗口",
    "Beginning of dialog window. Escape will cancel and close the window.":
        "打开对话窗口。Escape键将取消并关闭对话窗口",
    "End of dialog window.": "结束对话窗口",
    "Seek to live, currently behind live": "尝试直播,当前为延时播放",
    "Seek to live, currently playing live": "尝试直播,当前为实时播放",
    "progress bar timing: currentTime={1} duration={2}": "{1}/{2}",
    "{1} is loading.": "正在加载 {1}。",
    "No content": "无内容",
});

defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

defineEmits(["finished"]);
</script>

<template>
    <div class="video-viewer">
        <video-player
            :src="fileUrl"
            playsinline
            controls
            class="video-player vjs-big-play-centered"
            language="zh-CN"
            @mounted="$emit('finished')"
        />
    </div>
</template>

<style scoped lang="less">
.video-viewer {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;

    :deep(.video-player) {
        width: 100%;
        height: 100%;
    }
}
</style>

图片(Image)

支持类型

image/jpeg(jpg、jpeg) image/png(png) image/gif(git) image/webp(webp) image/svg+xml(svg)

实现原理

使用NImage组件实现

html 复制代码
<script setup>
defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);
</script>

<template>
    <div class="image-viewer">
        <n-image
            :src="fileUrl"
            object-fit="scale-down"
            :on-load="emits('finished')"
        />
    </div>
</template>

<style scoped lang="less">
.image-viewer {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;
}
</style>

DXF

支持类型

image/vnd.dxf(dxf)

实现原理

使用DxfViewer组件实现

html 复制代码
<script setup>
import { ref, toRaw, onMounted } from "vue";
import { DxfViewer } from "dxf-viewer";
import * as THREE from "three";
import SimFangFont from "@/assets/fonts/simfang.ttf";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

const dxfContainerRef = ref();
const showAll = ref(true);
const dxfViewer = ref(null);
const layers = ref([]);

onMounted(() => {
    dxfViewer.value = new DxfViewer(dxfContainerRef.value, {
        clearColor: new THREE.Color("white"),
        autoResize: true,
        colorCorrection: true,
        // 一般是用gbk,也可以换成别的,比如:utf-8
        fileEncoding: "gbk",
        sceneOptions: {
            wireframeMesh: true,
        },
    });
    for (const eventName of ["loaded"]) {
        dxfViewer.value.Subscribe(eventName, () => {
            const dxfLayers = dxfViewer.value.GetLayers();
            dxfLayers.forEach((lyr) => (lyr["isVisible"] = true));
            layers.value = dxfLayers;
        });
    }
    toRaw(dxfViewer.value)
        .Load({
            url: props.fileUrl,
            fonts: [SimFangFont],
        })
        .then(() => emits("finished"));
});

const onToggleLayer = (layer, newState) => {
    layer.isVisible = newState;
    toRaw(dxfViewer.value).ShowLayer(layer.name, newState);
};
const onToggleAll = (newState) => {
    showAll.value = newState;
    for (const layer of layers.value) {
        if (layer.isVisible !== newState) {
            onToggleLayer(layer, newState);
        }
    }
};
</script>

<template>
    <div class="dxf-viewer">
        <n-split
            direction="horizontal"
            :default-size="0.9"
            :max="0.9"
            :min="0.1"
        >
            <template #1>
                <div ref="dxfContainerRef" class="dxf-container"></div>
            </template>
            <template #2>
                <div class="layer-list">
                    <n-scrollbar>
                        <div
                            v-show="layers.length > 0"
                            class="mb-10 display-flex"
                        >
                            <n-checkbox
                                v-model:checked="showAll"
                                label="全部图层"
                                @update:checked="onToggleAll"
                            />
                        </div>
                        <n-flex vertical align="start">
                            <div v-for="layer in layers" :key="layer.name">
                                <n-checkbox
                                    v-model:checked="layer.isVisible"
                                    @update:checked="
                                        (e) => onToggleLayer(layer, e)
                                    "
                                >
                                    <n-ellipsis>
                                        {{ layer.displayName }}
                                    </n-ellipsis>
                                </n-checkbox>
                            </div>
                        </n-flex>
                    </n-scrollbar>
                </div>
            </template>
        </n-split>
    </div>
</template>

<style lang="less" scoped>
.dxf-viewer {
    width: 100%;
    height: 100%;

    .layer-list {
        padding: 10px;
    }

    .dxf-container {
        width: 100%;
        height: 100%;
    }
}
</style>

JSON

支持类型

  • application/json(json)
  • application/json;charset=utf-8(json)

实现原理

使用vue-json-viewer组件实现

html 复制代码
<script setup>
import { onMounted, ref } from "vue";
import axios from "axios";
import JsonViewer from "vue-json-viewer";
import "vue-json-viewer/style.css";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

const content = ref(null);

onMounted(async () => {
    content.value = (
        await axios.get(props.fileUrl, {
            responseType: "json",
        })
    ).data;
    emits("finished");
});
</script>

<template>
    <div class="json-viewer">
        <json-viewer
            :value="content"
            copyable
            :expand-depth="10"
            expanded
            show-double-quotes
            show-array-index
        />
    </div>
</template>

<style scoped lang="less">
.json-viewer {
    width: 100%;
    height: 100%;
    text-align: left;
}
</style>

Markdown

支持类型

  • text/x-web-markdown(md)

实现原理

使用mavonEditor组件实现

html 复制代码
<script setup>
import { mavonEditor } from "mavon-editor";
import { onMounted, ref } from "vue";
import axios from "axios";
import "mavon-editor/dist/css/index.css";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

const content = ref(null);

onMounted(async () => {
    content.value = (
        await axios.get(props.fileUrl, {
            responseType: "text",
        })
    ).data;
    emits("finished");
});
</script>

<template>
    <div class="markdown-viewer">
        <mavon-editor
            v-model="content"
            :subfield="false"
            default-open="preview"
            :editable="false"
            :toolbars-flag="false"
        />
    </div>
</template>

<style scoped lang="less">
.markdown-viewer {
    width: 100%;
    height: 100%;
}
</style>

纯文本

支持类型

  • text/plain(txt)
  • ...

实现原理

使用pre标签实现

html 复制代码
<script setup>
import { onMounted, ref } from "vue";
import axios from "axios";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

const content = ref(null);

onMounted(async () => {
    content.value = (
        await axios.get(props.fileUrl, {
            responseType: "text",
        })
    ).data;
    emits("finished");
});
</script>

<template>
    <div class="plain-text-viewer">
        <pre>{{ content }}</pre>
    </div>
</template>

<style scoped lang="less">
.plain-text-viewer {
    padding: 10px;
    width: 100%;
    height: 100%;

    > pre {
        font-size: 16px;
        text-align: left;
    }
}
</style>

KML

支持类型

  • application/vnd.google-earth.kml+xml(kml)

实现原理

使用Cesium库实现

html 复制代码
<script setup>
import { Ellipsoid, Ion, KmlDataSource, Viewer } from "cesium";
import { onMounted } from "vue";
import "cesium/Build/Cesium/Widgets/widgets.css";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

onMounted(() => {
    Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_TOKEN;
    const viewer = new Viewer("cesium-container", {
        animation: false, //左下角的动画仪表盘
        baseLayerPicker: false, //右上角的图层选择按钮
        homeButton: false, //home按钮
        sceneModePicker: false, //模式切换按钮
        timeline: false, //底部的时间轴
        geocoder: false,
        navigationHelpButton: false, //右上角的帮助按钮,
        fullscreenButton: false, //右下角的全屏按钮
        selectionIndicator: false, //原生自带绿色选择框,双击显示的绿框
        infoBox: false, //点击要素之后显示的信息窗口
        //sceneMode: SceneMode.SCENE2D,
        ellipsoid: Ellipsoid.WGS84,
    });

    viewer.dataSources
        .add(
            KmlDataSource.load(props.fileUrl, {
                camera: viewer.scene.camera,
                canvas: viewer.scene.canvas,
                screenOverlayContainer: viewer.container,
            }),
        )
        .then((dataSource) => {
            viewer.flyTo(dataSource.entities);
        });
    emits("finished");
});
</script>

<template>
    <div id="cesium-container" class="kml-viewer"></div>
</template>

<!--suppress Stylelint -->
<style scoped lang="less">
.kml-viewer {
    width: 100%;
    height: 100%;

    :deep(.cesium-credit-logoContainer) {
        display: none !important;
    }
}
</style>

注意事项

需要将.env文件中的VITE_CESIUM_TOKEN 值,配置为自己Cesium账号的TOKEN

3D模型

支持类型

  • model/obj(obj)
  • model/gltf-binary(glb)
  • model/x.stl-binary(stl)

实现原理

使用 babylonjs 库和 @google/model-viewer 组件实现

html 复制代码
<script setup>
import { onMounted, nextTick, ref } from "vue";
import "@google/model-viewer";
import * as BABYLON from "babylonjs";
import "babylonjs-loaders";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
    mimeType: {
        type: String,
        required: true,
    },
    filename: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

const canvasRef = ref();

onMounted(() => {
    if (props.mimeType === "model/gltf-binary") {
        emits("finished");
    } else {
        nextTick(async () => {
            // 获取画布元素
            const canvas = canvasRef.value;
            // 创建引擎
            const engine = new BABYLON.Engine(canvas, true);
            const scene = new BABYLON.Scene(engine);

            // 创建相机
            const camera = new BABYLON.ArcRotateCamera(
                "camera1",
                Math.PI / 2,
                Math.PI / 2,
                5,
                BABYLON.Vector3.Zero(),
                scene,
            );
            camera.attachControl(canvas, true);

            // 创建光源
            const light = new BABYLON.HemisphericLight(
                "light1",
                BABYLON.Vector3.Up(),
                scene,
            );
            light.intensity = 0.7;

            try {
                // 加载模型
                const result = await BABYLON.SceneLoader.ImportMeshAsync(
                    "",
                    props.fileUrl, // 替换为模型路径
                    props.filename, // 替换为模型文件名
                    scene,
                );
                // 模型加载完成后的操作
                const meshes = result.meshes;

                // 合并所有网格的边界框
                let minimum = new BABYLON.Vector3(
                    Number.MAX_VALUE,
                    Number.MAX_VALUE,
                    Number.MAX_VALUE,
                );
                let maximum = new BABYLON.Vector3(
                    -Number.MAX_VALUE,
                    -Number.MAX_VALUE,
                    -Number.MAX_VALUE,
                );

                meshes.forEach((mesh) => {
                    const boundingBox = mesh.getBoundingInfo().boundingBox;
                    minimum = BABYLON.Vector3.Minimize(
                        minimum,
                        boundingBox.minimumWorld,
                    );
                    maximum = BABYLON.Vector3.Maximize(
                        maximum,
                        boundingBox.maximumWorld,
                    );
                });

                // 计算模型的中心和大小
                const center = minimum.add(maximum).scale(0.5);
                const size = maximum.subtract(minimum);
                const maxSize = Math.max(size.x, size.y, size.z);

                // 设置相机目标和距离
                camera.target = center;
                camera.radius = maxSize * 2; // 调整相机的距离

                // 设置相机裁剪平面
                camera.minZ = maxSize / 100; // 根据模型大小动态调整
                camera.maxZ = maxSize * 10;

                // 启动渲染循环
                engine.runRenderLoop(() => {
                    scene.render();
                });

                emits("finished");
            } catch (error) {
                console.error("模型加载失败:", error);
            }

            // 窗口尺寸变化时调整画布大小
            window.addEventListener("resize", () => {
                engine.resize();
            });
        });
    }
});
</script>

<template>
    <model-viewer
        v-if="mimeType === 'model/gltf-binary'"
        class="model-viewer"
        :src="fileUrl"
        shadow-intensity="1"
        camera-controls
    ></model-viewer>
    <div v-else class="model-viewer">
        <canvas ref="canvasRef" style="width: 100%; height: 100vh"></canvas>
    </div>
</template>

<style scoped lang="less">
.model-viewer {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;
}
</style>

注意事项

如果为obj模型,则文件地址必须为根路径,如:xxxxx.com/{文件md5}

OFFICE

支持类型

  • application/vnd.openxmlformats-officedocument.wordprocessingml.document(docx)
  • application/vnd.openxmlformats-officedocument.spreadsheetml.sheet(xlsx)
  • application/vnd.openxmlformats-officedocument.presentationml.presentation(pptx)
  • application/msword(doc)
  • application/vnd.ms-excel(xls)
  • application/vnd.ms-powerpoint(ppt)

实现原理

使用only-office 库或微软提供的在线预览服务实现

html 复制代码
<script setup>
import { onMounted } from "vue";
import { RandomStringUtils, StringUtils } from "pangju-utils";

const props = defineProps({
    fileUrl: {
        type: String,
        required: true,
    },
    filename: {
        type: String,
        required: true,
    },
    mimeType: {
        type: String,
        required: true,
    },
});

const emits = defineEmits(["finished"]);

onMounted(async () => {
    if (!window.DocsAPI) {
        location.href = `${
            import.meta.env.VITE_OFFICE_VIEW_URL
        }?src=${encodeURIComponent(props.fileUrl)}`;
    } else {
        // 配置文档查看器
        const baseName = StringUtils.substringBeforeLast(props.filename, ".");
        const extension = StringUtils.substringAfterLast(props.filename, ".");

        let documentType = null;
        switch (props.mimeType) {
            case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
            case "application/msword":
                documentType = "word";
                break;
            case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
            case "application/vnd.ms-excel":
                documentType = "cell";
                break;
            case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
            case "application/vnd.ms-powerpoint":
                documentType = "slide";
                break;
        }

        // eslint-disable-next-line no-undef
        window.docEditor = new DocsAPI.DocEditor("only-office-container", {
            documentType: documentType,
            type: "desktop",
            height: "100%",
            width: "100%",
            document: {
                fileType: extension,
                key: RandomStringUtils.randomLetter(10),
                title: baseName,
                url: props.fileUrl,
                permissions: {
                    copy: true,
                    download: true,
                    print: true,
                },
            },
            editorConfig: {
                customization: {
                    anonymous: {
                        request: false,
                    },
                    chat: false,
                    comments: false,
                },
                lang: "zh",
                mode: "view",
            },
        });
    }
    emits("finished");
});
</script>

<template>
    <div id="only-office-container"></div>
</template>

注意事项

如果使用onlyoffice,则需要在index.html中添加

html 复制代码
<script type="text/javascript" src="http://xxxx/web-apps/apps/api/documents/api.js">

总结

后边我会更新更多的文件类型预览实现,如果有错误请在评论区指正,谢谢!

相关推荐
程序员荒生12 分钟前
基于 Next.js 搞定个人公众号登陆流程
前端·微信·开源
AmyGeng12323 分钟前
el-dropdown全屏模式下不展示下拉菜单处理
javascript·vue.js·ecmascript
deckcode26 分钟前
css基础-选择器
前端·css
倔强青铜三27 分钟前
WXT浏览器开发中文教程(2)----WXT项目目录结构详解
前端·javascript·vue.js
1024小神31 分钟前
html5-qrcode前端打开摄像头扫描二维码功能
前端·html·html5
beibeibeiooo31 分钟前
【Vue3入门2】01-图片轮播示例
前端·vue.js
倔强青铜三32 分钟前
WXT浏览器开发中文教程(1)----安装WXT
前端·javascript·vue.js
计算机-秋大田1 小时前
基于Spring Boot的产业园区智慧公寓管理系统的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
2301_815357701 小时前
Spring:IOC
java·前端·spring
萌萌哒草头将军1 小时前
🍍Pinia党福音,🍍Pinia伴侣:🍍pinia-colada
前端·javascript·vue.js