hookehuyr

feat(office): 添加Office文档预览功能组件

添加OfficeViewer组件支持docx/excel/pptx文件预览
在StudyDetailPage中集成Office文档预览功能
添加相关依赖包@vue-office/docx/excel/pptx和vue-demi
......@@ -29,6 +29,9 @@
"@vant/touch-emulator": "^1.4.0",
"@vant/use": "^1.6.0",
"@videojs-player/vue": "^1.0.0",
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"@vue-office/pptx": "^1.0.1",
"browser-md5-file": "^1.1.1",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
......@@ -38,6 +41,7 @@
"vconsole": "^3.15.1",
"video.js": "^7.21.7",
"vue": "^3.5.13",
"vue-demi": "0.14.6",
"vue-router": "^4.5.0",
"weixin-js-sdk": "^1.6.5"
},
......
......@@ -13,13 +13,16 @@ declare module 'vue' {
AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default']
BottomNav: typeof import('./components/layout/BottomNav.vue')['default']
CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default']
CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default']
ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default']
CourseCard: typeof import('./components/ui/CourseCard.vue')['default']
CourseList: typeof import('./components/courses/CourseList.vue')['default']
FormPage: typeof import('./components/infoEntry/formPage.vue')['default']
FrostedGlass: typeof import('./components/ui/FrostedGlass.vue')['default']
GradientHeader: typeof import('./components/ui/GradientHeader.vue')['default']
LiveStreamCard: typeof import('./components/ui/LiveStreamCard.vue')['default']
MenuItem: typeof import('./components/ui/MenuItem.vue')['default']
OfficeViewer: typeof import('./components/ui/OfficeViewer.vue')['default']
PdfPreview: typeof import('./components/ui/PdfPreview.vue')['default']
ReviewPopup: typeof import('./components/courses/ReviewPopup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
......
<template>
<div class="office-viewer">
<!-- 错误状态 -->
<div v-if="error" class="error-container">
<van-icon name="warning-o" size="24" color="#ff6b6b" />
<span class="error-text">{{ error }}</span>
<van-button
type="primary"
size="small"
@click="retry"
class="retry-btn"
>
重试
</van-button>
</div>
<!-- 文档预览 -->
<div v-else class="document-container">
<!-- DOCX 文档预览 -->
<vue-office-docx
v-if="fileType === 'docx'"
:src="src"
:style="{ height: containerHeight }"
@rendered="onRendered"
@error="onError"
/>
<!-- Excel 文档预览 -->
<vue-office-excel
v-else-if="fileType === 'excel'"
:src="src"
:style="{ height: containerHeight }"
@rendered="onRendered"
@error="onError"
/>
<!-- PPTX 文档预览 -->
<vue-office-pptx
v-else-if="fileType === 'pptx'"
:src="src"
:style="{ height: containerHeight }"
@rendered="onRendered"
@error="onError"
/>
<!-- 不支持的文件类型 -->
<div v-else class="unsupported-container">
<van-icon name="warning-o" size="24" color="#ff6b6b" />
<span class="unsupported-text">不支持的文件类型: {{ fileType }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficePptx from '@vue-office/pptx'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
// Props 定义
const props = defineProps({
// 文件 URL 或 ArrayBuffer 数据
src: {
type: [String, ArrayBuffer],
required: true
},
// 文件类型
fileType: {
type: String,
required: true,
validator: (value) => ['docx', 'excel', 'pptx'].includes(value)
},
// 容器高度
height: {
type: String,
default: '70vh'
}
})
// Emits 定义
const emit = defineEmits(['rendered', 'error', 'retry'])
// 响应式数据
const error = ref('')
// 计算属性
const containerHeight = computed(() => props.height)
/**
* 文档渲染完成回调
*/
const onRendered = () => {
console.log('Office document rendered successfully')
error.value = ''
emit('rendered')
}
/**
* 文档渲染错误回调
* @param {Error} err - 错误对象
*/
const onError = (err) => {
console.error('Office document render error:', err)
error.value = '文档加载失败,请检查文件格式或网络连接'
emit('error', err)
}
/**
* 重试加载文档
*/
const retry = () => {
console.log('Retrying to load office document')
error.value = ''
emit('retry')
}
/**
* 监听 src 变化,重新加载文档
*/
watch(() => props.src, (newSrc) => {
console.log('Office document src changed:', newSrc)
if (newSrc) {
error.value = ''
// 验证 URL 是否有效
if (typeof newSrc === 'string') {
// 检查是否是有效的 URL
try {
new URL(newSrc)
console.log('Valid URL detected:', newSrc)
} catch (e) {
// 可能是相对路径,检查是否以 http 开头
if (!newSrc.startsWith('http') && !newSrc.startsWith('/')) {
console.warn('Invalid URL format:', newSrc)
error.value = '文档地址格式不正确'
return
}
}
}
}
}, { immediate: true })
/**
* 监听 fileType 变化
*/
watch(() => props.fileType, (newType) => {
console.log('Office document fileType changed:', newType)
})
// 组件挂载时的初始化
onMounted(() => {
console.log('OfficeViewer 组件已挂载')
})
</script>
<style lang="less" scoped>
.office-viewer {
width: 100%;
height: 100%;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 12px;
padding: 20px;
.error-text {
font-size: 14px;
color: #666;
text-align: center;
line-height: 1.5;
}
.retry-btn {
margin-top: 8px;
}
}
.unsupported-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 12px;
padding: 20px;
.unsupported-text {
font-size: 14px;
color: #666;
text-align: center;
line-height: 1.5;
}
}
.document-container {
width: 100%;
height: 100%;
overflow: auto;
// 覆盖 vue-office 默认样式
:deep(.vue-office-docx),
:deep(.vue-office-excel),
:deep(.vue-office-pptx) {
border: none;
border-radius: 8px;
min-height: 100%;
}
// Excel 表格样式优化
:deep(.vue-office-excel) {
.luckysheet-cell-main {
font-size: 12px;
}
}
// DOCX 文档样式优化
:deep(.vue-office-docx) {
.docx-wrapper {
padding: 16px;
min-height: 100%;
background: #fff;
}
}
// PPTX 演示文稿样式优化
:deep(.vue-office-pptx) {
.pptx-wrapper {
background: #f5f5f5;
min-height: 100%;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.office-viewer {
border-radius: 0;
box-shadow: none;
.document-container {
// 移动端确保滚动流畅
-webkit-overflow-scrolling: touch;
:deep(.vue-office-docx) {
.docx-wrapper {
padding: 12px;
font-size: 14px;
line-height: 1.6;
}
}
:deep(.vue-office-excel) {
.luckysheet-cell-main {
font-size: 11px;
}
}
:deep(.vue-office-pptx) {
.pptx-wrapper {
padding: 8px;
}
}
}
}
}
</style>
......@@ -122,10 +122,10 @@ const handleMounted = (payload) => {
const errorCode = player.value.error();
if (errorCode) {
console.error('错误代码:', errorCode.code, '错误信息:', errorCode.message);
// 显示用户友好的错误信息
showErrorOverlay.value = true;
// 根据错误类型进行处理
switch (errorCode.code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
......@@ -231,10 +231,10 @@ const retryLoad = () => {
errorMessage.value = '重试次数已达上限,请稍后再试';
return;
}
retryCount.value++;
showErrorOverlay.value = false;
if (player.value && !player.value.isDisposed()) {
console.log(`第${retryCount.value}次重试加载视频`);
player.value.load();
......@@ -431,7 +431,7 @@ defineExpose({
visibility: visible !important;
margin-right: 8px;
}
:deep(.vjs-playback-rate .vjs-playback-rate-value) {
font-size: 1.3em;
padding: 0 6px;
......@@ -441,14 +441,14 @@ defineExpose({
width: auto;
margin: 0;
}
:deep(.vjs-playback-rate .vjs-menu) {
min-width: 100px;
max-height: 180px;
bottom: 120%;
right: -10px;
}
:deep(.vjs-playback-rate .vjs-menu-item) {
padding: 12px 16px;
font-size: 16px;
......@@ -464,7 +464,7 @@ defineExpose({
:deep(.vjs-playback-rate) {
margin-right: 6px;
}
:deep(.vjs-playback-rate .vjs-playback-rate-value) {
font-size: 1.2em;
padding: 0 4px;
......@@ -474,12 +474,12 @@ defineExpose({
width: auto;
margin: 0;
}
:deep(.vjs-playback-rate .vjs-menu) {
min-width: 90px;
right: -5px;
}
:deep(.vjs-playback-rate .vjs-menu-item) {
padding: 10px 12px;
font-size: 15px;
......
......@@ -265,6 +265,32 @@
<!-- PDF预览 -->
<PdfPreview v-model:show="pdfShow" :url="pdfUrl" :title="pdfTitle" @onLoad="onPdfLoad" />
<!-- Office 文档预览弹窗 -->
<van-popup
v-model:show="officeShow"
position="center"
round
closeable
:style="{ height: '80%', width: '90%' }"
>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3>
</div>
<div class="flex-1 overflow-auto">
<OfficeViewer
v-if="officeShow && officeUrl && officeFileType"
:src="officeUrl"
:file-type="officeFileType"
height="100%"
@rendered="onOfficeRendered"
@error="onOfficeError"
@retry="onOfficeRetry"
/>
</div>
</div>
</van-popup>
<!-- 音频播放器弹窗 -->
<van-popup
v-model:show="audioShow"
......@@ -542,9 +568,18 @@
<!-- 移动端:根据文件类型显示不同的预览按钮 -->
<template v-else>
<!-- Office 文档显示预览按钮 -->
<button
v-if="isOfficeFile(file.url)"
@click="showOfficeDocument(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="description" size="16" />
文档预览
</button>
<!-- PDF文件显示在线查看按钮 -->
<button
v-if="file.url && file.url.toLowerCase().includes('.pdf')"
v-else-if="file.url && file.url.toLowerCase().includes('.pdf')"
@click="showPdf(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
......@@ -613,6 +648,7 @@ import { useTitle } from '@vueuse/core';
import VideoPlayer from '@/components/ui/VideoPlayer.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
import FrostedGlass from '@/components/ui/FrostedGlass.vue';
import OfficeViewer from '@/components/ui/OfficeViewer.vue';
import dayjs from 'dayjs';
import { formatDate, wxInfo } from '@/utils/tools'
import axios from 'axios';
......@@ -817,6 +853,12 @@ const pdfShow = ref(false);
const pdfTitle = ref('');
const pdfUrl = ref('');
// Office 文档预览相关
const officeShow = ref(false);
const officeTitle = ref('');
const officeUrl = ref('');
const officeFileType = ref('');
// 音频播放器相关
const audioShow = ref(false);
const audioTitle = ref('');
......@@ -842,6 +884,46 @@ const showPdf = ({ title, url, meta_id }) => {
};
/**
* 显示 Office 文档预览
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showOfficeDocument = ({ title, url, meta_id }) => {
console.log('showOfficeDocument called with:', { title, url, meta_id });
// 清理 URL 中的反引号和多余空格
const cleanUrl = url.replace(/`/g, '').trim();
officeTitle.value = title;
officeUrl.value = cleanUrl;
officeFileType.value = getOfficeFileType(cleanUrl);
console.log('Office document props set:', {
title: officeTitle.value,
url: officeUrl.value,
fileType: officeFileType.value
});
// 验证 URL 格式
try {
new URL(cleanUrl);
console.log('URL validation passed:', cleanUrl);
} catch (error) {
console.error('Invalid URL format:', cleanUrl, error);
showToast('文档链接格式不正确');
return;
}
officeShow.value = true;
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
};
/**
* 显示音频播放器
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
......@@ -901,6 +983,31 @@ const onPdfLoad = (load) => {
// console.warn('pdf加载状态', load);
};
/**
* Office 文档渲染完成回调
*/
const onOfficeRendered = () => {
console.log('Office 文档渲染完成');
showToast('文档加载完成');
};
/**
* Office 文档渲染错误回调
* @param {Error} error - 错误对象
*/
const onOfficeError = (error) => {
console.error('Office 文档渲染失败:', error);
showToast('文档加载失败,请重试');
};
/**
* Office 文档重试回调
*/
const onOfficeRetry = () => {
console.log('重试加载 Office 文档');
// 可以在这里添加重新获取文档的逻辑
};
const courseId = computed(() => {
return route.params.id || '';
});
......@@ -1396,6 +1503,44 @@ const isImageFile = (fileName) => {
}
/**
* 判断文件是否为 Office 文档
* @param {string} fileName - 文件名
* @returns {boolean} 是否为 Office 文档
*/
const isOfficeFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const officeTypes = ['docx', 'xlsx', 'xls', 'pptx'];
return officeTypes.includes(extension);
}
/**
* 获取 Office 文档类型
* @param {string} fileName - 文件名
* @returns {string} 文档类型 (docx, excel, pptx)
*/
const getOfficeFileType = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return '';
}
const extension = fileName.split('.').pop().toLowerCase();
if (extension === 'docx') {
return 'docx';
} else if (extension === 'xlsx' || extension === 'xls') {
return 'excel';
} else if (extension === 'pptx') {
return 'pptx';
}
return '';
}
/**
* 音频播放事件
* @param audio 音频对象
*/
......
......@@ -713,6 +713,21 @@
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz#71a8fc82d4d2e425af304c35bf389506f674d89b"
integrity sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==
"@vue-office/docx@^1.6.3":
version "1.6.3"
resolved "https://registry.yarnpkg.com/@vue-office/docx/-/docx-1.6.3.tgz#0c183b13553c029fea702eb8b8b4260a7e1f0961"
integrity sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==
"@vue-office/excel@^1.7.14":
version "1.7.14"
resolved "https://registry.yarnpkg.com/@vue-office/excel/-/excel-1.7.14.tgz#6e1446abae9690a09eed6f5e8c09bb923b3d5b10"
integrity sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==
"@vue-office/pptx@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@vue-office/pptx/-/pptx-1.0.1.tgz#f34d5a7aa78cd534c5724540dbe53f0539e94b3e"
integrity sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==
"@vue/babel-helper-vue-transform-on@1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.4.0.tgz#616020488692a9c42a613280d62ed1b727045d95"
......@@ -2855,6 +2870,11 @@ vite@^6.2.0:
optionalDependencies:
fsevents "~2.3.3"
vue-demi@0.14.6:
version "0.14.6"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.6.tgz#dc706582851dc1cdc17a0054f4fec2eb6df74c92"
integrity sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==
vue-router@^4.5.0:
version "4.5.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.5.1.tgz#47bffe2d3a5479d2886a9a244547a853aa0abf69"
......