基于 Vue3 + VueOffice 的多格式文档预览组件实现(支持 PDF/Word/Excel/PPT)

1. 组件概述

在 Web 项目中,文档预览是高频需求(如 教育平台、OA 系统、文件管理平台),传统方案多依赖后端转换(如将 Office 文件转 HTML/PDF),存在响应慢、兼容性差等问题。本文实现的组件基于Vue3 + VueOffice ,前端直接预览 PDF、Word(doc/docx)、Excel(xls/xlsx)、PPT(ppt/pptx)四种主流格式,无需后端额外处理,同时支持Ctrl + 滚轮缩放拖拽移动双击重置、加载状态提示、错误重试等交互功能,体验更接近本地文档查看器。

2. 核心技术栈

技术 / 库 作用说明
Vue3(Script Setup) 组件核心框架,使用 Setup 语法糖简化代码结构
VueOffice 前端文档预览核心库,提供各格式预览子组件
- @vue-office/pdf PDF 格式预览组件
- @vue-office/docx Word 格式预览组件
- @vue-office/excel Excel 格式预览组件
- @vue-office/pptx PPT 格式预览组件
Less(Scoped) 组件样式开发,Scoped 避免样式污染
Element Plus Icon 加载 / 错误状态图标(如 el-icon-loading)

3. 组件实现细节

组件采用 Vue3 单文件组件(SFC)结构,分为 Template(视图)、Script Setup(逻辑)、Style(样式)三部分,以下逐部分解析。

3.1 Template:视图结构设计

Template 核心是容器 + 条件渲染的预览组件 + 状态提示,通过事件绑定实现交互,通过动态类和指令控制视图显示。

javascript 复制代码
<template>
  <!-- 文档预览容器:绑定缩放、拖拽相关事件 -->
  <div
    class="office-viewer"
    :class="{ dragging: isDragging, zoomable: zoomLevel > 1 }"
    @wheel="handleWheel"        <!-- 滚轮缩放 -->
    @mousedown="handleMouseDown"<!-- 开始拖拽 -->
    @mousemove="handleMouseMove"<!-- 拖拽中 -->
    @mouseup="handleMouseUp"    <!-- 结束拖拽 -->
    @mouseleave="handleMouseLeave"<!-- 鼠标离开 -->
    ref="viewerContainer"
  >
    <!-- 1. PDF预览:条件渲染(trimmedType === 'pdf') -->
    <VueOfficePdf
      v-if="trimmedType === 'pdf'"
      ref="pdfViewer"
      :src="encodedSrc"
      :style="{
        height: height,
        width: width,
        // 缩放 + 拖拽偏移:transform组合属性
        transform: `scale(${zoomLevel}) translate(${dragOffset.x}px, ${dragOffset.y}px)`,
        transformOrigin: 'center center'  <!-- 缩放原点:中心 -->
      }"
      @rendered="onRendered"     <!-- 渲染完成回调 -->
      @error="onError"           <!-- 渲染错误回调 -->
    />

    <!-- 2. Word预览(支持doc/docx) -->
    <VueOfficeDocx
      v-else-if="trimmedType === 'docx' || trimmedType === 'doc'"
      ref="docxViewer"
      :src="encodedSrc"
      :style="{ /* 同PDF,缩放+拖拽 */ }"
      @rendered="onRendered"
      @error="onError"
    />

    <!-- 3. Excel预览(支持xls/xlsx) -->
    <VueOfficeExcel
      v-else-if="trimmedType === 'xlsx' || trimmedType === 'xls'"
      ref="excelViewer"
      :src="encodedSrc"
      :style="{ /* 同PDF */ }"
      @rendered="onRendered"
      @error="onError"
    />

    <!-- 4. PPT预览(支持ppt/pptx):注意transformOrigin为top left -->
    <VueOfficePptx
      v-else-if="trimmedType === 'ppt' || trimmedType === 'pptx'"
      ref="pptxViewer"
      :src="encodedSrc"
      :options="pptxOptions"      <!-- PPT专属配置(如图片渲染、渲染模式) -->
      :style="{
        height: height,
        width: width,
        transform: `scale(${zoomLevel}) translate(${dragOffset.x}px, ${dragOffset.y}px)`,
        transformOrigin: 'top left'  <!-- PPT缩放原点:左上角(适配幻灯片布局) -->
      }"
      @rendered="onPptxRendered"  <!-- PPT专属渲染回调(含调试日志) -->
      @error="onPptxError"        <!-- PPT专属错误回调 -->
    />

    <!-- 5. 不支持的文件类型提示 -->
    <div v-else class="unsupported-type">
      <div class="error-message">
        <i class="el-icon-warning"></i>
        <p>不支持的文件类型: {{ type }}</p>
        <p>支持的格式: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX</p>
      </div>
    </div>

    <!-- 6. 加载状态(覆盖层) -->
    <div v-if="loading" class="loading-overlay">
      <div class="loading-spinner">
        <i class="el-icon-loading"></i>
        <p>文档加载中...</p>
      </div>
    </div>

    <!-- 7. 错误状态(覆盖层:含重试按钮) -->
    <div v-if="error" class="error-overlay">
      <div class="error-message">
        <i class="el-icon-warning"></i>
        <p>文档加载失败</p>
        <p>{{ errorMessage }}</p>
        <button @click="retry" class="retry-btn">重试</button>
      </div>
    </div>

    <!-- 8. 缩放提示(2秒后自动隐藏) -->
    <div v-if="showZoomTip" class="zoom-tip">
      <span>缩放: {{ Math.round(zoomLevel * 100) }}%</span>
      <small>Ctrl+滚轮缩放,拖拽移动,双击重置</small>
    </div>
  </div>
</template>

关键设计点

  • 容器事件:通过@wheel@mousedown等原生事件绑定,实现无依赖的缩放和拖拽;
  • 动态样式:用transform组合scale(缩放)和translate(拖拽),性能优于修改top/left
  • 缩放原点差异:PPT 用top left(幻灯片默认从左上角开始布局),其他格式用center center(居中缩放更自然);
  • 状态覆盖层:加载 / 错误状态用绝对定位覆盖容器,避免遮挡文档内容,同时提供明确反馈。

3.2 Script Setup:逻辑核心实现

Script 部分采用 Vue3 Setup 语法糖,逻辑模块化(依赖引入、Props、响应式数据、方法、生命周期),代码简洁且易维护。

3.2.1 依赖引入与基础配置
javascript 复制代码
import { ref, computed, watch, onMounted, readonly, nextTick } from "vue";
// 引入VueOffice各格式预览组件
import VueOfficePdf from "@vue-office/pdf";
import VueOfficeDocx from "@vue-office/docx/lib/v3/index.js";
import "@vue-office/docx/lib/v3/index.css";  // Word组件样式(必须引入)
import VueOfficeExcel from "@vue-office/excel/lib/v3/index.js";
import "@vue-office/excel/lib/v3/index.css"; // Excel组件样式
import VueOfficePptx from "@vue-office/pptx";
3.2.2 Props 定义(外部传入参数)

通过defineProps定义组件入参,包含类型验证默认值,确保传入参数合法:

javascript 复制代码
const props = defineProps({
  // 文件类型(必须:如pdf、docx)
  type: {
    type: String,
    required: true,
    // 验证器:仅允许支持的格式
    validator: (value) =>
      ["pdf", "docx", "doc", "xlsx", "xls", "ppt", "pptx"].includes(value.trim().toLowerCase()),
  },
  // 文件地址(必须:如URL或base64)
  src: {
    type: String,
    required: true,
  },
  // 容器高度(默认600px)
  height: {
    type: String,
    default: "600px",
  },
  // 容器宽度(默认100%)
  width: {
    type: String,
    default: "100%",
  },
});

// 定义组件对外事件(如渲染完成、错误)
const emit = defineEmits(["rendered", "error", "loading"]);
3.2.3 响应式数据(状态管理)

ref定义组件内部状态,涵盖加载 / 错误状态缩放 / 拖拽参数DOM 引用等:

javascript 复制代码
// 状态类
const loading = ref(false);    // 加载中
const error = ref(false);      // 错误状态
const errorMessage = ref("");  // 错误信息
const showZoomTip = ref(false); // 缩放提示显示
const zoomTipTimer = ref(null); // 提示隐藏定时器

// 缩放类
const zoomLevel = ref(1);      // 缩放比例(0.5~3)

// 拖拽类
const isDragging = ref(false); // 是否正在拖拽
const dragStart = ref({ x: 0, y: 0 }); // 拖拽起点
const dragOffset = ref({ x: 0, y: 0 }); // 拖拽偏移量
const lastDragOffset = ref({ x: 0, y: 0 }); // 上次拖拽偏移

// DOM引用(用于操作组件内部DOM)
const viewerContainer = ref(null);
const pdfViewer = ref(null);
const docxViewer = ref(null);
const excelViewer = ref(null);
const pptxViewer = ref(null);

// PPT专属配置(图片渲染、渲染模式等)
const pptxOptions = ref({
  enableImages: true,    // 启用图片渲染(默认false,需手动开启)
  renderMode: "canvas",  // 渲染模式(canvas/svg)
  scale: 1,              // 初始缩放比例
  debug: true,           // 调试模式(控制台输出日志)
});
3.2.4 计算属性(派生状态)

通过computed处理 Props 或状态,避免重复计算:

javascript 复制代码
// 处理文件类型(去空格、转小写)
const trimmedType = computed(() => props.type.trim().toLowerCase());

// 检查文件类型是否支持
const isSupported = computed(() => 
  ["pdf", "docx", "doc", "xlsx", "xls", "ppt", "pptx"].includes(trimmedType.value)
);

// 文件地址编码(可扩展:如处理特殊字符,当前直接返回src)
const encodedSrc = computed(() => props.src);
3.2.5 核心方法(交互与逻辑)
(1)文档渲染与错误处理
javascript 复制代码
// 通用渲染完成回调(PDF/Word/Excel)
const onRendered = () => {
  loading.value = false;
  error.value = false;
  emit("rendered"); // 对外通知渲染完成
};

// 通用错误回调(PDF/Word/Excel)
const onError = (err) => {
  loading.value = false;
  error.value = true;
  errorMessage.value = err.message || "文档加载失败";
  emit("error", err); // 对外通知错误
};

// PPT专属渲染回调(含调试日志)
const onPptxRendered = () => {
  console.log("PPTX渲染完成:", { src: encodedSrc.value, type: trimmedType.value });
  onRendered(); // 复用通用渲染逻辑
};

// PPT专属错误回调(含调试日志)
const onPptxError = (err) => {
  console.error("PPTX渲染失败:", err, { src: encodedSrc.value, type: trimmedType.value });
  onError(err); // 复用通用错误逻辑
};

// 重试加载(重置错误状态,重新触发加载)
const retry = () => {
  error.value = false;
  errorMessage.value = "";
  loading.value = true; // 重新进入加载状态
};
(2)缩放功能(Ctrl + 滚轮 + 双击重置)
javascript 复制代码
// 滚轮缩放(仅按住Ctrl键生效)
const handleWheel = (event) => {
  if (!event.ctrlKey) return; // 未按Ctrl键,不处理
  event.preventDefault(); // 阻止页面滚动

  // 计算新缩放比例:滚轮向上+0.1,向下-0.1,限制在0.5~3之间
  const delta = event.deltaY > 0 ? -0.1 : 0.1;
  const newZoomLevel = Math.max(0.5, Math.min(3, zoomLevel.value + delta));
  
  zoomLevel.value = newZoomLevel;
  showZoomTip.value = true; // 显示缩放提示

  // 2秒后隐藏提示(清除旧定时器,避免多次触发)
  if (zoomTipTimer.value) clearTimeout(zoomTipTimer.value);
  zoomTipTimer.value = setTimeout(() => showZoomTip.value = false, 2000);
};

// 双击重置(缩放比例1,拖拽偏移0)
const handleDoubleClick = () => {
  zoomLevel.value = 1;
  dragOffset.value = { x: 0, y: 0 };
  lastDragOffset.value = { x: 0, y: 0 };
  showZoomTip.value = true;

  // 1秒后隐藏提示
  if (zoomTipTimer.value) clearTimeout(zoomTipTimer.value);
  zoomTipTimer.value = setTimeout(() => showZoomTip.value = false, 1000);
};

// 重置缩放(对外暴露方法)
const resetZoom = () => {
  zoomLevel.value = 1;
  dragOffset.value = { x: 0, y: 0 };
  lastDragOffset.value = { x: 0, y: 0 };
};
(3)拖拽功能(仅缩放后生效)
javascript 复制代码
// 开始拖拽(记录鼠标起点)
const handleMouseDown = (event) => {
  if (zoomLevel.value <= 1) return; // 缩放比例≤1时,不允许拖拽
  isDragging.value = true;
  // 计算起点:当前鼠标位置 - 上次拖拽偏移(避免拖拽复位)
  dragStart.value = {
    x: event.clientX - dragOffset.value.x,
    y: event.clientY - dragOffset.value.y,
  };
  event.preventDefault();
};

// 拖拽中(计算实时偏移)
const handleMouseMove = (event) => {
  if (!isDragging.value) return;
  dragOffset.value = {
    x: event.clientX - dragStart.value.x,
    y: event.clientY - dragStart.value.y,
  };
  event.preventDefault();
};

// 结束拖拽(记录上次偏移)
const handleMouseUp = () => {
  if (isDragging.value) {
    isDragging.value = false;
    lastDragOffset.value = { ...dragOffset.value }; // 保存当前偏移
  }
};

// 鼠标离开(同结束拖拽)
const handleMouseLeave = () => handleMouseUp();
(4)滚动位置重置(切换文档时回到顶部)
javascript 复制代码
// 重置VueOffice组件内部滚动位置
const resetOfficeViewerScroll = () => {
  nextTick(() => { // 确保DOM更新后执行
    // PDF组件
    if (pdfViewer.value?.$el) {
      const container = pdfViewer.value.$el.querySelector('.pdf-viewer') || pdfViewer.value.$el;
      container.scrollTop = 0;
    }
    // Word/Excel/PPT组件同理(省略重复代码,见原代码)
  });
};
3.2.6 生命周期与监听
javascript 复制代码
// 组件挂载时初始化
onMounted(() => {
  // 若传入合法src和支持的类型,进入加载状态
  if (props.src && isSupported.value) loading.value = true;
  // 绑定双击事件(容器DOM)
  viewerContainer.value?.addEventListener("dblclick", handleDoubleClick);
});

// 监听src变化(切换文档时重新加载)
watch(
  () => props.src,
  (newSrc) => {
    if (newSrc) {
      loading.value = true;
      error.value = false;
      viewerContainer.value?.scrollTop = 0; // 容器滚动到顶部
      resetOfficeViewerScroll(); // 重置组件内部滚动
    }
  },
  { immediate: true } // 初始加载时执行
);

// 监听type变化(切换文件类型时重新加载)
watch(
  () => props.type,
  (newType) => {
    if (newType && props.src) {
      loading.value = true;
      error.value = false;
    }
  }
);

// 对外暴露方法(父组件可调用)
defineExpose({
  resetZoom,          // 重置缩放
  resetOfficeViewerScroll, // 重置滚动
  zoomLevel: readonly(zoomLevel), // 只读缩放比例
  // 其他需要暴露的方法(如PPT回调)
});

3.3 Style:样式优化(Less + Scoped)

样式采用 Less 编写,通过scoped避免污染全局,核心优化点:

  1. 容器交互样式(cursor 随状态变化:grab/grabbing/default);
  2. 覆盖层布局(加载 / 错误状态居中,半透明背景);
  3. 修改 VueOffice 默认样式(如背景色从灰色改为白色);
  4. 动画效果(加载图标旋转动画)。

关键样式解析:

javascript 复制代码
<style lang="less" scoped>
.office-viewer {
  position: relative;
  width: 100%;
  height: 100%;
  background: #fff;
  border-radius: 4px;
  overflow: hidden;
  cursor: grab; // 默认光标:抓取
  user-select: none; // 禁止文本选中

  // 拖拽中光标
  &:active, &.dragging {
    cursor: grabbing;
  }

  // 缩放后光标(仅zoomLevel>1时)
  &.zoomable {
    cursor: grab;
  }

  // 未缩放时光标
  &:not(.zoomable) {
    cursor: default;
  }

  // 缩放提示:右上角悬浮
  .zoom-tip {
    position: absolute;
    top: 20px;
    right: 20px;
    background: rgba(0,0,0,0.8);
    color: #fff;
    padding: 8px 12px;
    border-radius: 4px;
    font-size: 12px;
    z-index: 1001; // 高于加载层
    pointer-events: none; // 不影响下层交互
  }

  // 加载层:居中覆盖
  .loading-overlay {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(255,255,255,0.9);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;

    .loading-spinner i {
      font-size: 32px;
      animation: rotate 2s linear infinite; // 旋转动画
    }
  }

  // 错误层:含重试按钮
  .error-overlay {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(255,255,255,0.95);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;

    .retry-btn {
      background: #409eff;
      color: #fff;
      border: none;
      padding: 8px 16px;
      border-radius: 4px;
      cursor: pointer;
      &:hover { background: #66b1ff; }
    }
  }
}

// 旋转动画(加载图标)
@keyframes rotate {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

// 修改VueOffice默认样式(需用:deep()穿透Scoped)
:deep(.vue-office-pdf .pdf-viewer),
:deep(.vue-office-docx .docx-viewer),
:deep(.vue-office-excel .excel-viewer) {
  background: #fff !important; // 覆盖默认灰色背景
}

:deep(.vue-office-docx) {
  width: 100% !important;
  height: 100% !important;
}
</style>

关键样式技巧

  • :deep():穿透 Scoped 样式,修改第三方组件(VueOffice)内部样式;
  • z-index管理:缩放提示(1001)> 加载 / 错误层(1000)> 文档内容(默认),避免遮挡;
  • 动画性能:用transform实现旋转动画,避免top/left导致的重排重绘。

4. 组件使用步骤

4.1 安装依赖

bash 复制代码
# 安装VueOffice各格式组件
npm install @vue-office/pdf @vue-office/docx @vue-office/excel @vue-office/pptx --save

# 安装Element Plus(用于图标)
npm install element-plus @element-plus/icons-vue --save

4.2 全局引入 Element Plus 图标(可选)

main.js中引入 Element Plus 图标,避免组件内重复引入:

javascript 复制代码
import { createApp } from 'vue';
import App from './App.vue';
import { ElIcon } from 'element-plus';
import { Warning, Loading } from '@element-plus/icons-vue';

const app = createApp(App);
app.component('ElIcon', ElIcon);
app.component('ElIconWarning', Warning);
app.component('ElIconLoading', Loading);
app.mount('#app');

4.3 父组件中使用预览组件

javascript 复制代码
<template>
  <div class="parent-container">
    <h2>文档预览示例</h2>
    <!-- 引入自定义预览组件 -->
    <OfficeViewer
      :type="fileType"
      :src="fileSrc"
      height="700px"
      @rendered="onDocRendered"
      @error="onDocError"
    />
  </div>
</template>

<script setup>
import OfficeViewer from './components/OfficeViewer.vue';
import { ref } from 'vue';

// 示例:PDF文件(src可替换为后端返回的URL或base64)
const fileType = ref('pdf');
const fileSrc = ref('https://example.com/test.pdf');

// 渲染完成回调
const onDocRendered = () => {
  console.log('文档预览完成');
};

// 错误回调
const onDocError = (err) => {
  console.error('文档预览失败:', err);
};
</script>

<style>
.parent-container {
  width: 80%;
  margin: 20px auto;
}
</style>

5. 常见问题与解决方案

5.1 PPT 图片不显示

  • 原因 :VueOffice PPT 组件默认不启用图片渲染(enableImages: false);
  • 解决方案 :在pptxOptions中设置enableImages: true

5.2 Word/Excel 样式错乱

  • 原因:未引入 VueOffice 对应组件的样式文件;
  • 解决方案 :确保引入@vue-office/docx/lib/v3/index.css@vue-office/excel/lib/v3/index.css

5.3 拖拽后偏移异常

  • 原因dragStart计算错误,未减去上次拖拽偏移;
  • 解决方案 :确认handleMouseDowndragStart的计算逻辑:x: event.clientX - dragOffset.value.x

5.4 跨域问题(src 为远程 URL)

  • 原因:浏览器同源策略限制,远程服务器未配置 CORS;
  • 解决方案
    1. 后端配置 CORS(允许前端域名访问);
    2. 用后端代理转发文件请求(如 Vue CLI 的devServer.proxy)。

6. 总结与扩展建议

6.1 组件优势

  1. 轻量化:前端直接预览,无需后端转换;
  2. 多格式支持:覆盖 PDF/Word/Excel/PPT 四大主流格式;
  3. 交互友好:支持缩放、拖拽、双击重置,贴近本地体验;
  4. 状态完善:加载 / 错误 / 不支持类型均有明确提示,含重试机制。

6.2 扩展方向

  1. 添加页码跳转:针对 PDF/Word,增加页码输入框和跳转按钮;
  2. 文档下载功能 :通过<a>标签结合src实现下载;
  3. 支持更多格式 :如 TXT、Markdown(可集成vue-markdown-editor);
  4. 性能优化:大文件(如 100 页 + PDF)可添加懒加载或分页渲染。

通过本文的实现,你可以快速在 Vue3 项目中集成多格式文档预览功能,减少后端依赖,提升用户体验。如果在使用过程中遇到问题,可参考 VueOffice 官方文档(https://vue-office.org/)或本文的常见问题解决方案。

7.完整代码

javascript 复制代码
<template>
    <div
        class="office-viewer"
        :class="{ dragging: isDragging, zoomable: zoomLevel > 1 }"
        @wheel="handleWheel"
        @mousedown="handleMouseDown"
        @mousemove="handleMouseMove"
        @mouseup="handleMouseUp"
        @mouseleave="handleMouseLeave"
        ref="viewerContainer"
    >
        <!-- PDF预览 -->
        <VueOfficePdf
            v-if="trimmedType === 'pdf'"
            ref="pdfViewer"
            :src="encodedSrc"
            :style="{
                height: height,
                width: width,
                transform: `scale(${zoomLevel}) translate(${dragOffset.x}px, ${dragOffset.y}px)`,
                transformOrigin: 'center center',
            }"
            @rendered="onRendered"
            @error="onError"
        />

        <!-- Word文档预览 -->
        <VueOfficeDocx
            v-else-if="trimmedType === 'docx' || trimmedType === 'doc'"
            ref="docxViewer"
            :src="encodedSrc"
            :style="{
                height: height,
                width: width,
                transform: `scale(${zoomLevel}) translate(${dragOffset.x}px, ${dragOffset.y}px)`,
                transformOrigin: 'center center',
            }"
            @rendered="onRendered"
            @error="onError"
        />

        <!-- Excel文档预览 -->
        <VueOfficeExcel
            v-else-if="trimmedType === 'xlsx' || trimmedType === 'xls'"
            ref="excelViewer"
            :src="encodedSrc"
            :style="{
                height: height,
                width: width,
                transform: `scale(${zoomLevel}) translate(${dragOffset.x}px, ${dragOffset.y}px)`,
                transformOrigin: 'center center',
            }"
            @rendered="onRendered"
            @error="onError"
        />

        <!-- PPT预览 -->
        <VueOfficePptx
            v-else-if="trimmedType === 'ppt' || trimmedType === 'pptx'"
            ref="pptxViewer"
            :src="encodedSrc"
            :options="pptxOptions"
            :style="{
                height: height,
                width: width,
                transform: `scale(${zoomLevel}) translate(${dragOffset.x}px, ${dragOffset.y}px)`,
                transformOrigin: 'top left',
            }"
            @rendered="onPptxRendered"
            @error="onPptxError"
        />

        <!-- 不支持的文件类型 -->
        <div v-else class="unsupported-type">
            <div class="error-message">
                <i class="el-icon-warning"></i>
                <p>不支持的文件类型: {{ type }}</p>
                <p>
                    支持的格式: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX
                </p>
            </div>
        </div>

        <!-- 加载状态 -->
        <div v-if="loading" class="loading-overlay">
            <div class="loading-spinner">
                <i class="el-icon-loading"></i>
                <p>文档加载中...</p>
            </div>
        </div>

        <!-- 错误状态 -->
        <div v-if="error" class="error-overlay">
            <div class="error-message">
                <i class="el-icon-warning"></i>
                <p>文档加载失败</p>
                <p>{{ errorMessage }}</p>
                <button @click="retry" class="retry-btn">重试</button>
            </div>
        </div>

        <!-- 缩放控制提示 -->
        <div v-if="showZoomTip" class="zoom-tip">
            <span>缩放: {{ Math.round(zoomLevel * 100) }}%</span>
            <small>Ctrl+滚轮缩放,拖拽移动,双击重置</small>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, watch, onMounted, readonly, nextTick } from "vue";
import VueOfficePdf from "@vue-office/pdf";
import VueOfficeDocx from "@vue-office/docx/lib/v3/index.js";
import "@vue-office/docx/lib/v3/index.css";
import VueOfficeExcel from "@vue-office/excel/lib/v3/index.js";
import "@vue-office/excel/lib/v3/index.css";
import VueOfficePptx from "@vue-office/pptx";

// 定义组件属性
const props = defineProps({
    // 文件类型: pdf, docx, doc, xlsx, xls, ppt, pptx
    type: {
        type: String,
        required: true,
        validator: (value) =>
            [
                "pdf",
                "docx",
                "doc",
                "xlsx",
                "xls",
                "ppt",
                "pptx",
            ].includes(value.trim().toLowerCase()),
    },
    // 文件源地址
    src: {
        type: String,
        required: true,
    },
    // 容器高度
    height: {
        type: String,
        default: "600px",
    },
    // 容器宽度
    width: {
        type: String,
        default: "100%",
    },
});

// 定义事件
const emit = defineEmits(["rendered", "error", "loading"]);

// 响应式数据
const loading = ref(false);
const error = ref(false);
const errorMessage = ref("");
const zoomLevel = ref(1);
const showZoomTip = ref(false);
const viewerContainer = ref(null);
const pdfViewer = ref(null);
const docxViewer = ref(null);
const excelViewer = ref(null);
const pptxViewer = ref(null);
let zoomTipTimer = null;

// 拖拽相关状态
const isDragging = ref(false);
const dragStart = ref({ x: 0, y: 0 });
const dragOffset = ref({ x: 0, y: 0 });
const lastDragOffset = ref({ x: 0, y: 0 });

// 支持的文件类型
const supportedTypes = [
    "pdf",
    "docx",
    "doc",
    "xlsx",
    "xls",
    "ppt",
    "pptx",
];

// PPTX配置选项
const pptxOptions = ref({
    // 启用图片渲染
    enableImages: true,
    // 设置渲染模式
    renderMode: "canvas",
    // 缩放比例
    scale: 1,
    // 启用调试模式
    debug: true,
});

// 处理去除空格的文件类型
const trimmedType = computed(() => {
    return props.type.trim().toLowerCase();
});

// 检查文件类型是否支持
const isSupported = computed(() => {
    return supportedTypes.includes(trimmedType.value);
});

// 对src进行URL编码
const encodedSrc = computed(() => {
    return props.src;
});

// 文档渲染完成回调
const onRendered = () => {
    loading.value = false;
    error.value = false;
    emit("rendered");
};

// 文档加载错误回调
const onError = (err) => {
    loading.value = false;
    error.value = true;
    errorMessage.value = err.message || "文档加载失败";
    emit("error", err);
};

// PPTX文档渲染完成回调
const onPptxRendered = () => {
    console.log("PPTX文档渲染完成");
    console.log("当前src:", encodedSrc.value);
    console.log("文档类型:", trimmedType.value);
    loading.value = false;
    error.value = false;
    emit("rendered");
};

// PPTX文档加载错误回调
const onPptxError = (err) => {
    console.error("PPTX文档渲染失败:", err);
    console.log("失败时的src:", encodedSrc.value);
    console.log("失败时的文档类型:", trimmedType.value);
    loading.value = false;
    error.value = true;
    errorMessage.value = err.message || "PPTX文档加载失败";
    emit("error", err);
};

// 重试加载
const retry = () => {
    error.value = false;
    errorMessage.value = "";
    loading.value = true;
};

// 处理鼠标滚轮缩放(需要按住Ctrl键)
const handleWheel = (event) => {
    // 只有按住Ctrl键时才进行缩放
    if (!event.ctrlKey) {
        return;
    }

    event.preventDefault();

    const delta = event.deltaY > 0 ? -0.1 : 0.1;
    const newZoomLevel = Math.max(0.5, Math.min(3, zoomLevel.value + delta));

    zoomLevel.value = newZoomLevel;

    // 显示缩放提示
    showZoomTip.value = true;

    // 清除之前的定时器
    if (zoomTipTimer) {
        clearTimeout(zoomTipTimer);
    }

    // 2秒后隐藏提示
    zoomTipTimer = setTimeout(() => {
        showZoomTip.value = false;
    }, 2000);
};

// 双击重置缩放
const handleDoubleClick = () => {
    zoomLevel.value = 1;
    dragOffset.value = { x: 0, y: 0 };
    lastDragOffset.value = { x: 0, y: 0 };
    showZoomTip.value = true;

    if (zoomTipTimer) {
        clearTimeout(zoomTipTimer);
    }

    zoomTipTimer = setTimeout(() => {
        showZoomTip.value = false;
    }, 1000);
};

// 重置缩放级别
const resetZoom = () => {
    zoomLevel.value = 1;
    // 重置拖拽偏移
    dragOffset.value = { x: 0, y: 0 };
    lastDragOffset.value = { x: 0, y: 0 };
};

// 重置vue-office组件内部滚动位置
const resetOfficeViewerScroll = () => {
    // 使用nextTick确保组件已经渲染
    nextTick(() => {
        // 重置PDF组件滚动位置
        if (pdfViewer.value && pdfViewer.value.$el) {
            const pdfContainer = pdfViewer.value.$el.querySelector('.pdf-viewer') || pdfViewer.value.$el;
            if (pdfContainer) {
                pdfContainer.scrollTop = 0;
            }
        }
        
        // 重置Word组件滚动位置
        if (docxViewer.value && docxViewer.value.$el) {
            const docxContainer = docxViewer.value.$el.querySelector('.docx-viewer') || docxViewer.value.$el;
            if (docxContainer) {
                docxContainer.scrollTop = 0;
            }
        }
        
        // 重置Excel组件滚动位置
        if (excelViewer.value && excelViewer.value.$el) {
            const excelContainer = excelViewer.value.$el.querySelector('.excel-viewer') || excelViewer.value.$el;
            if (excelContainer) {
                excelContainer.scrollTop = 0;
            }
        }
        
        // 重置PPT组件滚动位置
        if (pptxViewer.value && pptxViewer.value.$el) {
            const pptxContainer = pptxViewer.value.$el.querySelector('.pptx-viewer') || pptxViewer.value.$el;
            if (pptxContainer) {
                pptxContainer.scrollTop = 0;
            }
        }
    });
};

// 处理鼠标按下事件(开始拖拽)
const handleMouseDown = (event) => {
    if (zoomLevel.value > 1) {
        isDragging.value = true;
        dragStart.value = {
            x: event.clientX - dragOffset.value.x,
            y: event.clientY - dragOffset.value.y,
        };
        event.preventDefault();
    }
};

// 处理鼠标移动事件(拖拽中)
const handleMouseMove = (event) => {
    if (isDragging.value && zoomLevel.value > 1) {
        dragOffset.value = {
            x: event.clientX - dragStart.value.x,
            y: event.clientY - dragStart.value.y,
        };
        event.preventDefault();
    }
};

// 处理鼠标释放事件(结束拖拽)
const handleMouseUp = () => {
    if (isDragging.value) {
        isDragging.value = false;
        lastDragOffset.value = { ...dragOffset.value };
    }
};

// 处理鼠标离开事件
const handleMouseLeave = () => {
    if (isDragging.value) {
        isDragging.value = false;
        lastDragOffset.value = { ...dragOffset.value };
    }
};

// 监听src变化,重新加载文档
watch(
    () => props.src,
    (newSrc) => {
        if (newSrc) {
            loading.value = true;
            error.value = false;
            errorMessage.value = "";
            // 重置滚动位置到顶部
            if (viewerContainer.value) {
                viewerContainer.value.scrollTop = 0;
            }
            // 重置vue-office组件内部滚动位置
            resetOfficeViewerScroll();
        }
    },
    { immediate: true }
);

// 监听type变化
watch(
    () => props.type,
    (newType) => {
        if (newType && props.src) {
            loading.value = true;
            error.value = false;
            errorMessage.value = "";
        }
    }
);

// 组件挂载时初始化
onMounted(() => {
    if (props.src && isSupported.value) {
        loading.value = true;
    }

    // 添加双击事件监听
    if (viewerContainer.value) {
        viewerContainer.value.addEventListener("dblclick", handleDoubleClick);
    }
});

// 暴露方法供外部调用
defineExpose({
    resetZoom,
    resetOfficeViewerScroll,
    zoomLevel: readonly(zoomLevel),
    handleMouseDown,
    handleMouseMove,
    handleMouseUp,
    handleMouseLeave,
    onPptxRendered,
    onPptxError,
    pptxOptions,
});
</script>

<style lang="less" scoped>
.office-viewer {
    position: relative;
    width: 100%;
    height: 100%;
    background: #ffffff;
    border-radius: 4px;
    overflow: hidden;
    box-sizing: border-box;
    cursor: grab;
    user-select: none;
    overflow-y: auto;
    &:active {
        cursor: grabbing;
    }

    &.dragging {
        cursor: grabbing;
    }

    &.zoomable {
        cursor: grab;

        &:hover {
            cursor: grab;
        }
    }

    &:not(.zoomable) {
        cursor: default;
    }

    // 确保所有子元素都使用border-box
    * {
        box-sizing: border-box;
    }

    // 缩放提示样式
    .zoom-tip {
        position: absolute;
        top: 20px;
        right: 20px;
        background: rgba(0, 0, 0, 0.8);
        color: white;
        padding: 8px 12px;
        border-radius: 4px;
        font-size: 12px;
        z-index: 1001;
        pointer-events: none;
        transition: opacity 0.3s ease;

        span {
            display: block;
            font-weight: 500;
            margin-bottom: 2px;
        }

        small {
            opacity: 0.8;
            font-size: 10px;
        }
    }

    // 加载状态样式
    .loading-overlay {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(255, 255, 255, 0.9);
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 1000;

        .loading-spinner {
            text-align: center;
            color: #409eff;

            i {
                font-size: 32px;
                margin-bottom: 12px;
                display: block;
                animation: rotate 2s linear infinite;
            }

            p {
                margin: 0;
                font-size: 14px;
                color: #666;
            }
        }
    }

    // 错误状态样式
    .error-overlay {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(255, 255, 255, 0.95);
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 1000;

        .error-message {
            text-align: center;
            color: #f56c6c;

            i {
                font-size: 48px;
                margin-bottom: 16px;
                display: block;
            }

            p {
                margin: 8px 0;
                font-size: 14px;
                color: #666;

                &:first-of-type {
                    font-size: 16px;
                    font-weight: 500;
                    color: #f56c6c;
                }
            }

            .retry-btn {
                margin-top: 16px;
                padding: 8px 16px;
                background: #409eff;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
                font-size: 14px;
                transition: background-color 0.3s;

                &:hover {
                    background: #66b1ff;
                }
            }
        }
    }

    // 不支持的文件类型样式
    .unsupported-type {
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;

        .error-message {
            text-align: center;
            color: #e6a23c;

            i {
                font-size: 48px;
                margin-bottom: 16px;
                display: block;
            }

            p {
                margin: 8px 0;
                font-size: 14px;
                color: #666;

                &:first-of-type {
                    font-size: 16px;
                    font-weight: 500;
                    color: #e6a23c;
                }
            }
        }
    }
}

// 旋转动画
@keyframes rotate {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}

// 修改vue-office组件内部的灰色背景
:deep(.vue-office-pdf .pdf-viewer) {
    background: white !important;
}

:deep(.vue-office-pdf canvas) {
    background: white !important;
}

:deep(.vue-office-docx .docx-viewer) {
    background: white !important;
}

:deep(.vue-office-excel .excel-viewer) {
    background: white !important;
}

:deep(.vue-office-docx) {
    width: 100% !important;
    height: 100% !important;
    background: white !important;
}
:deep(.docx-wrapper) {
    background: white !important;
}
</style>

8.成果预览

PDF:
docx:
xlsx:
PPT:
相关推荐
Yvonne爱编码2 小时前
AJAX入门-AJAX 概念和 axios 使用
前端·javascript·ajax·html·js
2501_927539302 小时前
PDF Reader 编辑阅读(Mac)
pdf·mac·pdf reader
CHANG_THE_WORLD2 小时前
BrotliCompressor压缩器封装,以及 PDF编码器介绍
pdf
在路上`3 小时前
前端学习之后端java小白(三)-sql外键约束一对多
java·前端·学习
一路向北North3 小时前
apache poi 导出复杂的excel表格
apache·excel
Pu_Nine_93 小时前
10 分钟上手 ECharts:从“能跑”到“生产级”的完整踩坑之旅
前端·javascript·echarts·css3·html5
東雪蓮☆4 小时前
从零开始掌握 Web 与 Nginx:入门详解
运维·服务器·前端·nginx
脑子慢且灵4 小时前
【JavaWeb】一个简单的Web浏览服务程序
java·前端·后端·servlet·tomcat·web·javaee
柯南二号4 小时前
【大前端】 断点续传 + 分片上传(大文件上传优化) 的前端示例
前端