基于 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 核心是容器 + 条件渲染的预览组件 + 状态提示,通过事件绑定实现交互,通过动态类和指令控制视图显示。
<template><!-- 文档预览容器:绑定缩放、拖拽相关事件 --><divclass="office-viewer":class="{ dragging: isDragging, zoomable: zoomLevel > 1 }"@wheel="handleWheel" <!-- 滚轮缩放 -->@mousedown="handleMouseDown"<!-- 开始拖拽 -->@mousemove="handleMouseMove"<!-- 拖拽中 -->@mouseup="handleMouseUp" <!-- 结束拖拽 -->@mouseleave="handleMouseLeave"<!-- 鼠标离开 -->ref="viewerContainer"><!-- 1. PDF预览:条件渲染(trimmedType === 'pdf') --><VueOfficePdfv-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) --><VueOfficeDocxv-else-if="trimmedType === 'docx' || trimmedType === 'doc'"ref="docxViewer":src="encodedSrc":style="{ /* 同PDF,缩放+拖拽 */ }"@rendered="onRendered"@error="onError"/><!-- 3. Excel预览(支持xls/xlsx) --><VueOfficeExcelv-else-if="trimmedType === 'xlsx' || trimmedType === 'xls'"ref="excelViewer":src="encodedSrc":style="{ /* 同PDF */ }"@rendered="onRendered"@error="onError"/><!-- 4. PPT预览(支持ppt/pptx):注意transformOrigin为top left --><VueOfficePptxv-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 依赖引入与基础配置
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
定义组件入参,包含类型验证和默认值,确保传入参数合法:
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 引用等:
// 状态类
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 或状态,避免重复计算:
// 处理文件类型(去空格、转小写)
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)文档渲染与错误处理
// 通用渲染完成回调(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 + 滚轮 + 双击重置)
// 滚轮缩放(仅按住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)拖拽功能(仅缩放后生效)
// 开始拖拽(记录鼠标起点)
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)滚动位置重置(切换文档时回到顶部)
// 重置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 生命周期与监听
// 组件挂载时初始化
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
避免污染全局,核心优化点:
- 容器交互样式(cursor 随状态变化:grab/grabbing/default);
- 覆盖层布局(加载 / 错误状态居中,半透明背景);
- 修改 VueOffice 默认样式(如背景色从灰色改为白色);
- 动画效果(加载图标旋转动画)。
关键样式解析:
<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 安装依赖
# 安装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 图标,避免组件内重复引入:
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 父组件中使用预览组件
<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
计算错误,未减去上次拖拽偏移; - 解决方案:确认
handleMouseDown
中dragStart
的计算逻辑:x: event.clientX - dragOffset.value.x
。
5.4 跨域问题(src 为远程 URL)
- 原因:浏览器同源策略限制,远程服务器未配置 CORS;
- 解决方案:
- 后端配置 CORS(允许前端域名访问);
- 用后端代理转发文件请求(如 Vue CLI 的
devServer.proxy
)。
6. 总结与扩展建议
6.1 组件优势
- 轻量化:前端直接预览,无需后端转换;
- 多格式支持:覆盖 PDF/Word/Excel/PPT 四大主流格式;
- 交互友好:支持缩放、拖拽、双击重置,贴近本地体验;
- 状态完善:加载 / 错误 / 不支持类型均有明确提示,含重试机制。
6.2 扩展方向
- 添加页码跳转:针对 PDF/Word,增加页码输入框和跳转按钮;
- 文档下载功能:通过
<a>
标签结合src
实现下载; - 支持更多格式:如 TXT、Markdown(可集成
vue-markdown-editor
); - 性能优化:大文件(如 100 页 + PDF)可添加懒加载或分页渲染。
通过本文的实现,你可以快速在 Vue3 项目中集成多格式文档预览功能,减少后端依赖,提升用户体验。如果在使用过程中遇到问题,可参考 VueOffice 官方文档(https://vue-office.org/)或本文的常见问题解决方案。
7.完整代码
<template><divclass="office-viewer":class="{ dragging: isDragging, zoomable: zoomLevel > 1 }"@wheel="handleWheel"@mousedown="handleMouseDown"@mousemove="handleMouseMove"@mouseup="handleMouseUp"@mouseleave="handleMouseLeave"ref="viewerContainer"><!-- PDF预览 --><VueOfficePdfv-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文档预览 --><VueOfficeDocxv-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文档预览 --><VueOfficeExcelv-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预览 --><VueOfficePptxv-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, pptxtype: {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>