在线文档支持

This commit is contained in:
zhonghua 2026-03-16 20:06:56 +08:00
parent ce7ee34666
commit 4e17ee281c
10 changed files with 1604 additions and 302 deletions

File diff suppressed because it is too large Load Diff

View File

@ -39,10 +39,10 @@
"@types/node": "^20.11.28", "@types/node": "^20.11.28",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.5.1",
"@playwright/test": "^1.58.2",
"orval": "^8.5.3", "orval": "^8.5.3",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"typescript": "~5.4.0", "typescript": "~5.4.0",
"unocss": "^66.6.6",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0", "unplugin-vue-components": "^0.26.0",
"unplugin-vue-router": "^0.19.2", "unplugin-vue-router": "^0.19.2",

View File

@ -1,20 +1,8 @@
<template> <template>
<a-modal <a-modal v-model:open="visible" :title="title" :width="modalWidth" :footer="null" centered @cancel="handleClose">
v-model:open="visible"
:title="title"
:width="modalWidth"
:footer="null"
centered
@cancel="handleClose"
>
<!-- PDF 预览 --> <!-- PDF 预览 -->
<div v-if="fileType === 'pdf'" class="preview-container pdf-container"> <div v-if="fileType === 'pdf'" class="preview-container pdf-container">
<iframe <iframe v-if="previewUrl" :src="previewUrl" class="pdf-iframe" frameborder="0"></iframe>
v-if="previewUrl"
:src="previewUrl"
class="pdf-iframe"
frameborder="0"
></iframe>
<div v-else class="preview-error"> <div v-else class="preview-error">
<FileTextOutlined style="font-size: 48px; color: #999;" /> <FileTextOutlined style="font-size: 48px; color: #999;" />
<p>无法预览此PDF文件</p> <p>无法预览此PDF文件</p>
@ -31,13 +19,7 @@
<div class="audio-info"> <div class="audio-info">
<h3>{{ fileName }}</h3> <h3>{{ fileName }}</h3>
</div> </div>
<audio <audio ref="audioRef" :src="previewUrl" controls class="audio-element" @error="handleMediaError">
ref="audioRef"
:src="previewUrl"
controls
class="audio-element"
@error="handleMediaError"
>
您的浏览器不支持音频播放 您的浏览器不支持音频播放
</audio> </audio>
</div> </div>
@ -45,39 +27,53 @@
<!-- 视频播放器 --> <!-- 视频播放器 -->
<div v-else-if="fileType === 'video'" class="preview-container video-container"> <div v-else-if="fileType === 'video'" class="preview-container video-container">
<video <video ref="videoRef" :src="previewUrl" controls class="video-element" @error="handleMediaError">
ref="videoRef"
:src="previewUrl"
controls
class="video-element"
@error="handleMediaError"
>
您的浏览器不支持视频播放 您的浏览器不支持视频播放
</video> </video>
</div> </div>
<!-- 图片预览 --> <!-- 图片预览 -->
<div v-else-if="fileType === 'image'" class="preview-container image-container"> <div v-else-if="fileType === 'image'" class="preview-container image-container">
<a-image <a-image :src="previewUrl" :preview="true" class="preview-image" @error="handleImageError" />
:src="previewUrl"
:preview="true"
class="preview-image"
@error="handleImageError"
/>
</div> </div>
<!-- PPT 预览使用 Office Online Google Docs 预览 --> <!-- PPT 预览 -->
<div v-else-if="fileType === 'ppt'" class="preview-container ppt-container"> <div v-else-if="fileType === 'ppt'" class="preview-container ppt-container">
<div class="ppt-notice"> <div class="ppt-notice">
<FilePptOutlined style="font-size: 48px; color: #ff7a45;" /> <FilePptOutlined style="font-size: 48px; color: #ff7a45;" />
<h3>PPT 文件预览</h3> <h3>PPT 文件预览</h3>
<p>PPT 文件建议下载后使用 PowerPoint WPS 查看</p> <p>使用 WebOffice 在新页面中预览</p>
<a-space> <a-space>
<a-button type="primary" @click="downloadFile"> <a-button type="primary" @click="openInWebOffice">
<DownloadOutlined /> 下载文件 <EyeOutlined /> WebOffice 预览
</a-button> </a-button>
<a-button @click="openInOfficeOnline"> </a-space>
<EyeOutlined /> 在线预览 </div>
</div>
<!-- Word 预览 -->
<div v-else-if="fileType === 'doc'" class="preview-container ppt-container">
<div class="ppt-notice">
<FileTextOutlined style="font-size: 48px; color: #1890ff;" />
<h3>Word 文件预览</h3>
<p>使用 WebOffice 在新页面中预览</p>
<a-space>
<a-button type="primary" @click="openInWebOffice">
<EyeOutlined /> WebOffice 预览
</a-button>
</a-space>
</div>
</div>
<!-- Excel 预览 -->
<div v-else-if="fileType === 'excel'" class="preview-container ppt-container">
<div class="ppt-notice">
<FileOutlined style="font-size: 48px; color: #52c41a;" />
<h3>Excel 文件预览</h3>
<p>使用 WebOffice 在新页面中预览</p>
<a-space>
<a-button type="primary" @click="openInWebOffice">
<EyeOutlined /> WebOffice 预览
</a-button> </a-button>
</a-space> </a-space>
</div> </div>
@ -95,17 +91,6 @@
</div> </div>
</div> </div>
<!-- 底部操作栏 -->
<div class="preview-footer" v-if="fileType !== 'other'">
<a-space>
<a-button @click="downloadFile">
<DownloadOutlined /> 下载
</a-button>
<a-button type="link" @click="openInNewTab">
<ExportOutlined /> 新窗口打开
</a-button>
</a-space>
</div>
</a-modal> </a-modal>
</template> </template>
@ -121,11 +106,14 @@ import {
ExportOutlined, ExportOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { openWebOffice } from '@/views/office/webOffice';
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
fileUrl: string; fileUrl: string;
fileName: string; fileName: string;
/** 可选,用于 WebOffice 预览时的文件标识 */
fileId?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -150,6 +138,8 @@ const fileType = computed(() => {
if (checkStr.endsWith('.pdf')) return 'pdf'; if (checkStr.endsWith('.pdf')) return 'pdf';
if (checkStr.endsWith('.ppt') || checkStr.endsWith('.pptx')) return 'ppt'; if (checkStr.endsWith('.ppt') || checkStr.endsWith('.pptx')) return 'ppt';
if (checkStr.match(/\.(doc|docx)$/)) return 'doc';
if (checkStr.match(/\.(xls|xlsx)$/)) return 'excel';
if (checkStr.match(/\.(mp3|wav|ogg|aac|flac|m4a)$/)) return 'audio'; if (checkStr.match(/\.(mp3|wav|ogg|aac|flac|m4a)$/)) return 'audio';
if (checkStr.match(/\.(mp4|webm|ogg|mov|avi|mkv)$/)) return 'video'; if (checkStr.match(/\.(mp4|webm|ogg|mov|avi|mkv)$/)) return 'video';
if (checkStr.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/)) return 'image'; if (checkStr.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/)) return 'image';
@ -223,18 +213,39 @@ const openInNewTab = () => {
window.open(previewUrl.value, '_blank'); window.open(previewUrl.value, '_blank');
}; };
// 使 Office Online PPT // WebOffice PPTWordExcel
const openInOfficeOnline = () => { const isOfficeFile = computed(
// Office Online 使 URL () => ['ppt', 'doc', 'excel'].includes(fileType.value)
);
// name type
const parseFileName = (name: string) => {
const lastDot = name.lastIndexOf('.');
if (lastDot === -1) return { name: name, type: '' };
return {
name: name.slice(0, lastDot),
type: name.slice(lastDot + 1).toLowerCase(),
};
};
// 使 WebOffice IMM
const openInWebOffice = () => {
const fullUrl = previewUrl.value; const fullUrl = previewUrl.value;
if (!fullUrl.startsWith('http')) { if (!fullUrl.startsWith('http')) {
message.warning('PPT 在线预览需要完整的文件 URL'); message.warning('WebOffice 预览需要完整的文件 URL');
return; return;
} }
const { name, type } = parseFileName(props.fileName);
// 使 Office Online if (!type) {
const officeUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fullUrl)}`; message.warning('无法识别文件类型');
window.open(officeUrl, '_blank'); return;
}
openWebOffice({
id: props.fileId || '',
url: fullUrl,
name,
type,
});
}; };
// //

View File

@ -2,6 +2,7 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import Antd from 'ant-design-vue'; import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css'; import 'ant-design-vue/dist/reset.css';
import 'virtual:uno.css';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';

View File

@ -455,6 +455,16 @@ const routes: RouteRecordRaw[] = [
}, },
], ],
}, },
{
path: '/office/WebOffice',
name: 'WebOffice',
component: () => import('@/views/office/WebOffice.vue'),
meta: { requiresAuth: true, title: '在线预览' },
},
{
path: '/weboffice',
redirect: '/office/WebOffice',
},
{ {
path: '/404', path: '/404',
name: 'NotFound', name: 'NotFound',

View File

@ -1,11 +1,11 @@
<template> <template>
<div v-if="!expire" ref="containerRef" class="w-full h-full"></div>
<div v-if="!expire" ref="containerRef" class="!w-full !h-full pos-fixed top-0 left-0 z-999"></div>
<div v-else class="flex justify-center"> <div v-else class="flex justify-center">
<div class="my-60px"> <div class="my-60px">
链接已失效!<span class=" cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span> 链接已失效!<span class=" cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span>
</div> </div>
</div> </div>
<!-- <Modal ref="modalRef" class="max-w-80%" width="1340px" v-model:open="open" :footer="null" title="在线资源"> <!-- <Modal ref="modalRef" class="max-w-80%" width="1340px" v-model:open="open" :footer="null" title="在线资源">
<div class="flex min-h-600px bg-#f5f5f5 flex-col "> <div class="flex min-h-600px bg-#f5f5f5 flex-col ">
@ -40,22 +40,16 @@
<script lang="ts" name="WebOffice" setup> <script lang="ts" name="WebOffice" setup>
import { onMounted, ref, nextTick, reactive, watch, onUnmounted, onBeforeUnmount } from 'vue'; import { onMounted, ref, nextTick, reactive, watch, onUnmounted, onBeforeUnmount } from 'vue';
import request from '/@/apis/fetch';
// import { Modal, Pagination } from 'ant-design-vue';
import { import {
generateWebofficeToken, generateWebofficeToken,
generateWebofficeTokenReadOnly, generateWebofficeTokenReadOnly,
refreshWebofficeToken, refreshWebofficeToken,
} from '@/api/imm.api'; } from '@/api/imm.api';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
// import { usePermission } from '@/hooks/web/usePermission';
// import { insertPPTImage, insertWordImage } from './webOffice';
import { getTemItem, TemObj } from './temObjs'; import { getTemItem, TemObj } from './temObjs';
import { createImgPreview } from '/@/components/Preview/index';
const containerRef = ref(null); const containerRef = ref<HTMLElement | null>(null);
const route = useRoute(); const route = useRoute();
const open = ref(false);
const expire = ref(false); const expire = ref(false);
const router = useRouter(); const router = useRouter();
let updateSizeInterval: any; let updateSizeInterval: any;
@ -64,15 +58,9 @@ onMounted(() => {
nextTick(() => { nextTick(() => {
init(containerRef.value); init(containerRef.value);
}) })
updateSizeInterval = setInterval(() => {
if (baseInstance.value) {
updateSize();
}
}, 1000 * 60 * 5)
}); });
const _temObj = ref<TemObj>({ const _temObj = ref<TemObj>({
id: '', id: '',
type: '',
isEdit: false, isEdit: false,
name: '', name: '',
url: '', url: '',
@ -82,12 +70,6 @@ const updateSize = async () => {
if (!onUnmountedUpdateSize.value) { if (!onUnmountedUpdateSize.value) {
return; return;
} }
if (!expire.value && _temObj.value.isEdit) {
var formData = new FormData();
formData.append('id', _temObj.value.id);
formData.append('type', _temObj.value.type);
navigator.sendBeacon('/activity/cms/cmsFilePublic/updateSize', formData);
}
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
updateSize(); updateSize();
@ -113,7 +95,7 @@ function home() {
router.replace("/datas"); router.replace("/datas");
} }
const baseInstance = ref<any>(null); const baseInstance = ref<any>(null);
async function init(mount, timeout = 10 * 60 * 1000) { async function init(mount: HTMLElement | null) {
if (!mount) { if (!mount) {
console.error('确保挂载节点元素存在。 一般在 onMounted 钩子中调用。'); console.error('确保挂载节点元素存在。 一般在 onMounted 钩子中调用。');
} }
@ -129,7 +111,7 @@ async function init(mount, timeout = 10 * 60 * 1000) {
_temObj.value = temObj; _temObj.value = temObj;
const url = decodeURIComponent(`oss://lesingle-activity${new URL(decodeURIComponent(temObj.url)).pathname}`); const url = decodeURIComponent(`oss://lesingle-kid-course${new URL(decodeURIComponent(temObj.url)).pathname}`);
let tokenInfo = await getTokenFun(url, temObj); let tokenInfo = await getTokenFun(url, temObj);
const instance = (window as any).aliyun.config({ const instance = (window as any).aliyun.config({
mount, mount,
@ -141,7 +123,7 @@ async function init(mount, timeout = 10 * 60 * 1000) {
Object.assign(tokenInfo, data); Object.assign(tokenInfo, data);
return { return {
token: tokenInfo.accessToken, token: tokenInfo.accessToken,
timeout, timeout: 10 * 60 * 1000,
}; };
}); });
}, },
@ -149,7 +131,7 @@ async function init(mount, timeout = 10 * 60 * 1000) {
baseInstance.value = instance; baseInstance.value = instance;
instance.setToken({ instance.setToken({
token: tokenInfo.accessToken, token: tokenInfo.accessToken,
timeout, timeout: 10 * 60 * 1000,
}); });
await instance.ready(); await instance.ready();
// const imgurl = 'http://image.activity.lesingle.com/activitymaterial/poster/%E5%8A%A8%E7%89%A9%E7%BB%98%E6%9C%AC_1736908271102.jpg'; // const imgurl = 'http://image.activity.lesingle.com/activitymaterial/poster/%E5%8A%A8%E7%89%A9%E7%BB%98%E6%9C%AC_1736908271102.jpg';
@ -207,36 +189,4 @@ async function refreshTokenFun(tokenInfo: any) {
</script> </script>
<style scoped lang="less"> <style scoped></style>
.activity {
font-weight: bold;
background: rgba(24, 144, 255, 0.08);
color: #1890FF;
}
.cardView {
margin-left: 12px;
padding-left: 12px;
margin-top: 12px;
flex-wrap: wrap;
&>div {
margin-right: 12px;
margin-top: 6px;
margin-bottom: 6px;
overflow: hidden;
border-radius: 6px;
width: 200px;
}
/*
img {
width: 200px;
height: 200px;
object-fit: contain;
}
*/
}
</style>

View File

@ -1,69 +1,73 @@
import { ref, watch } from "vue";
import { ref, watch, } from 'vue';
/** /**
* localStorage缓存使使 * localStorage缓存使使
*/ */
const TemKel = '_t' const TemKel = "_t";
/** /**
* *
*/ */
const base_time = 1000 * 60 * 60 * 8; const base_time = 1000 * 60 * 60 * 8;
type Tem = { type Tem = {
[key: string]: { [key: string]: {
time: number, time: number;
val: TemObj val: TemObj;
} };
} };
export function initilTemItem() { export function initilTemItem() {
const tem: Tem = JSON.parse(localStorage.getItem(TemKel) || "{}"); const tem: Tem = JSON.parse(localStorage.getItem(TemKel) || "{}");
const _time = Date.now(); const _time = Date.now();
const _tem: Tem = {}; const _tem: Tem = {};
for (let key in tem) { for (let key in tem) {
const val = tem[key]; const val = tem[key];
if (_time - val.time < base_time) { if (_time - val.time < base_time) {
_tem[key] = val _tem[key] = val;
}
} }
}
localStorage.setItem(TemKel, JSON.stringify(_tem)); localStorage.setItem(TemKel, JSON.stringify(_tem));
} }
export type TemObj = { export type TemObj = {
id: string; id: string;
url: string; url: string;
name: string; name: string;
type: string; isEdit: boolean;
isEdit: boolean; };
} export function setTemItem(
export function setTemItem(val: TemObj, time: Number = Date.now() + base_time): string { val: TemObj,
const key = `_t${Date.now()}${Math.floor(Math.random() * 100000)}`; time: Number = Date.now() + base_time,
let Tem: Tem = {}; ): string {
try { const key = `_t${Date.now()}${Math.floor(Math.random() * 100000)}`;
Tem = JSON.parse(localStorage.getItem(TemKel) || "{}"); let Tem: Tem = {};
} catch (error) { } try {
Tem[key] = { Tem = JSON.parse(localStorage.getItem(TemKel) || "{}");
time: Date.now(), } catch (error) {}
val: val Tem[key] = {
} time: Date.now(),
localStorage.setItem(TemKel, JSON.stringify(Tem)); val: val,
};
localStorage.setItem(TemKel, JSON.stringify(Tem));
return key return key;
} }
export function getTemItem(key: string): TemObj | null { export function getTemItem(key: string): TemObj | null {
let Tem: Tem = {}; let Tem: Tem = {};
try { try {
Tem = JSON.parse(localStorage.getItem(TemKel) || "{}"); Tem = JSON.parse(localStorage.getItem(TemKel) || "{}");
return Tem[key].val return Tem[key].val;
} catch (error) { } } catch (error) {}
return null; return null;
} }
/** /**
* *
* @param val * @param val
*/ */
export function getCacheVal(key: string, defaultVal: any) { export function getCacheVal(key: string, defaultVal: any) {
const _val = ref(Number(localStorage.getItem(key) || defaultVal)); const _val = ref(Number(localStorage.getItem(key) || defaultVal));
watch(() => _val.value, () => { watch(
localStorage.setItem(key, `${_val.value}`) () => _val.value,
}) () => {
return _val localStorage.setItem(key, `${_val.value}`);
} },
);
return _val;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'unocss';
export default defineConfig({
shortcuts: {
'flex-center': 'flex items-center justify-center',
'flex-between': 'flex items-center justify-between',
'flex-col-center': 'flex flex-col items-center justify-center',
},
});

View File

@ -6,10 +6,12 @@ import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'; import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
import viteCompression from 'vite-plugin-compression'; import viteCompression from 'vite-plugin-compression';
import fileRouter from 'unplugin-vue-router/vite'; import fileRouter from 'unplugin-vue-router/vite';
import UnoCSS from 'unocss/vite';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
UnoCSS(),
fileRouter({ fileRouter({
routesFolder: 'src/views', routesFolder: 'src/views',
extensions: ['.vue'], extensions: ['.vue'],