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
避免污染全局,核心优化点:
- 容器交互样式(cursor 随状态变化:grab/grabbing/default);
- 覆盖层布局(加载 / 错误状态居中,半透明背景);
- 修改 VueOffice 默认样式(如背景色从灰色改为白色);
- 动画效果(加载图标旋转动画)。
关键样式解析:
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
计算错误,未减去上次拖拽偏移; - 解决方案 :确认
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.完整代码
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:
