feat: KidsMode 文档/视频预览调整,player 增强

- KidsMode: 视频对接 player 组件,文档对接 WebOffice 组件
- WebOffice: 新增 noPage 嵌入模式,支持 props 传入 url/fileName
- player: 参考 VideoPlayer 增强功能(title、emit、键盘快捷键、加载遮罩、唯一ID)

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-17 14:43:08 +08:00
parent 155f5f230b
commit 459fa434ac
4 changed files with 361 additions and 218 deletions

View File

@ -1,9 +1,10 @@
<template>
<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 class="my-60px">
链接已失效!<span class=" cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span>
<div v-if="!expire" ref="containerRef" class="!w-full !h-full z-999" :class="noPage ? 'absolute top-0 left-0' : 'pos-fixed top-0 left-0'"></div>
<div v-else class="flex justify-center items-center w-full h-full">
<div class="my-60px text-center">
链接已失效!
<span v-if="!noPage" class="cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span>
<span v-else class="block mt-10px color-#999">文档预览加载失败</span>
</div>
</div>
<!-- <Modal ref="modalRef" class="max-w-80%" width="1340px" v-model:open="open" :footer="null" title="在线资源">
@ -38,7 +39,7 @@
</template>
<!-- 阿里云IMM weboffice -->
<script lang="ts" name="WebOffice" setup>
import { onMounted, ref, nextTick, reactive, watch, onUnmounted, onBeforeUnmount } from 'vue';
import { onMounted, ref, nextTick, onUnmounted, onBeforeUnmount } from 'vue';
import {
generateWebofficeToken,
@ -48,16 +49,39 @@ import {
import { useRoute, useRouter } from 'vue-router';
import { getTemItem, TemObj } from './temObjs';
const props = defineProps<{
/** 嵌入模式:与页面共存,使用 absolute 定位 */
noPage?: boolean;
/** 文档 URL嵌入模式必传 */
url?: string;
/** 文件名(嵌入模式,用于 IMM */
fileName?: string;
/** 文件 ID嵌入模式可选 */
fileId?: string;
}>();
const containerRef = ref<HTMLElement | null>(null);
const route = useRoute();
const expire = ref(false);
const router = useRouter();
let updateSizeInterval: any;
// const { hasPermission } = usePermission();
function getTemObjFromProps(): TemObj | null {
if (!props.url) return null;
const ext = props.url.split('.').pop()?.split('?')[0] || 'pdf';
const name = props.fileName?.includes('.') ? props.fileName : `${props.fileName || 'document'}.${ext}`;
return {
id: props.fileId || '',
isEdit: false,
name,
url: encodeURIComponent(props.url),
};
}
onMounted(() => {
nextTick(() => {
init(containerRef.value);
})
});
});
const _temObj = ref<TemObj>({
id: '',
@ -98,12 +122,16 @@ const baseInstance = ref<any>(null);
async function init(mount: HTMLElement | null) {
if (!mount) {
console.error('确保挂载节点元素存在。 一般在 onMounted 钩子中调用。');
return;
}
let temObj: TemObj | null;
if (props.noPage && props.url) {
temObj = getTemObjFromProps();
} else {
temObj = getTemItem(route.query._t as string);
}
// IMM vue3 https://help.aliyun.com/zh/imm/user-guide/vue3-usage?spm=a2c4g.11186623.0.0.3a0244142zAkss
// token
// let tokenInfo = await props.getTokenFun(props.teachingMaterialsImmUrl);
const temObj = getTemItem(route.query._t as string);
if (!temObj) {
expire.value = true;
return;
@ -161,7 +189,7 @@ async function init(mount: HTMLElement | null) {
instance.on('fileStatus', () => {
debouncedFn(5000);
});
instance.ApiEvent.AddApiEventListener('error', (err) => {
instance.ApiEvent.AddApiEventListener('error', (err: unknown) => {
console.log('发生错误:', err);
})

View File

@ -1,70 +1,78 @@
<template>
<div class="w-full h-full bg-#000 z-50 top-0 left-0" :class="noPage ? 'absolute' : 'fixed '">
<!-- <PressDrag @click="toBreak"
class="pos-absolute bg-#00000033 cursor-pointer w-30px h-30px flex-center z-99999 p-10px top-20px right-20px rounded-50%">
<CloseCircleOutlined class="text-30px color-#ffffff" />
</PressDrag> -->
<div class="w-full h-full z-1" id="playerView"></div>
<div
ref="playerWrapperRef"
class="player-wrapper w-full h-full bg-#000 z-50 top-0 left-0"
:class="noPage ? 'absolute' : 'fixed'"
tabindex="0"
@keydown="handleKeydown"
>
<!-- 视频标题遮罩暂停时显示参考 VideoPlayer -->
<div v-if="title && !isPlaying && !loading" class="title-overlay">
<div class="title-text">{{ title }}</div>
</div>
<!-- 加载中遮罩 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
</div>
<!-- Aliplayer 挂载点 -->
<div :id="playerId" class="w-full h-full z-1"></div>
</div>
</template>
<script lang="ts" setup>
import { CloseCircleOutlined } from '@ant-design/icons-vue';
import PressDrag from '@/components/PressDrag.vue';
import { useRoute, useRouter } from 'vue-router';
import { getTemItem, TemObj } from './temObjs';
// @ts-ignore
const player = ref<typeof Aliplayer>(null); //
const route = useRoute();
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { useRoute } from 'vue-router';
import { getTemItem, type TemObj } from './temObjs';
const props = defineProps<{
url: string;
cover?: string;
noPage?: boolean;
/** 视频标题(参考 VideoPlayer */
title?: string;
}>();
const emit = defineEmits<{
(e: 'ended'): void;
(e: 'play'): void;
(e: 'pause'): void;
}>();
const route = useRoute();
const playerWrapperRef = ref<HTMLElement | null>(null);
const playerInstance = ref<any>(null);
const isPlaying = ref(false);
const loading = ref(true);
// ID
const playerId = ref(`playerView-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
const _temObj = ref<TemObj>({
id: '',
name: '',
isEdit: false,
url: '',
});
onMounted(() => {
nextTick(() => {
if (!props.noPage) {
const temObj = getTemItem(route.query._t as string);
if (!temObj) {
return;
}
_temObj.value = temObj;
createPlayer(_temObj.value.url, '/long/long.svg');
} else if (!!props.url) {
createPlayer(props.url, props.cover || '/long/long.svg');
}
})
function createPlayer(source?: string, cover?: string) {
if (!source) return;
});
watch(() => props.url, (newVal) => {
if (!!newVal) {
createPlayer(newVal, props.cover || '/long/long.svg');
}
});
const createPlayer = (source?: string, cover?: string) => {
// 使https://help.aliyun.com/zh/vod/developer-reference/integration
const mountEl = document.getElementById(playerId.value);
if (!mountEl) return;
// @ts-ignore
player.value = new Aliplayer(
playerInstance.value = new Aliplayer(
{
id: 'playerView',
id: playerId.value,
width: '100%',
height: '100%',
source: source, // vid/playauth/encryptType
cover: cover,
source: source,
cover: cover || '/long/long.svg',
skinLayout: [
{ name: 'bigPlayButton', align: 'blabs', x: 30, y: 80 },
{
name: 'H5Loading',
align: 'cc',
},
{ name: 'H5Loading', align: 'cc' },
{
name: 'controlBar',
align: 'blabs',
@ -81,43 +89,162 @@ const createPlayer = (source?: string, cover?: string) => {
},
],
},
// @ts-ignore
(player: typeof Aliplayer) => {
//
(player: any) => {
player.on('ended', () => {
// update(videoList[index + 1]);
isPlaying.value = false;
emit('ended');
});
},
);
console.log('player', player.value);
};
function toBreak() {
player.on('play', () => {
isPlaying.value = true;
loading.value = false;
emit('play');
});
player.on('pause', () => {
isPlaying.value = false;
emit('pause');
});
player.on('canplay', () => {
loading.value = false;
});
player.on('waiting', () => {
loading.value = true;
});
});
}
//
// const update = (video: PlayInfo) => {
// // playObj.value = video;
// player.value.dispose(); //
// createPlayer(video.Source, video.CoverURL); //
// };
function dispose() {
if (player.value) {
player.value.dispose(); //
if (playerInstance.value) {
try {
playerInstance.value.dispose();
} catch (_) {}
playerInstance.value = null;
}
}
onBeforeUnmount(() => {
try {
function initPlayer() {
if (!props.noPage) {
const temObj = getTemItem(route.query._t as string);
if (!temObj) return;
_temObj.value = temObj;
createPlayer(_temObj.value.url, '/long/long.svg');
} else if (props.url) {
createPlayer(props.url, props.cover || '/long/long.svg');
}
}
// VideoPlayer
function handleKeydown(e: KeyboardEvent) {
const p = playerInstance.value;
if (!p) return;
switch (e.key) {
case ' ':
e.preventDefault();
if (isPlaying.value) p.pause(); else p.play();
break;
case 'ArrowLeft':
e.preventDefault();
p.seek?.(Math.max(0, (p.getCurrentTime?.() ?? 0) - 10));
break;
case 'ArrowRight':
e.preventDefault();
const duration = p.getDuration?.() ?? 0;
p.seek?.(Math.min(duration, (p.getCurrentTime?.() ?? 0) + 10));
break;
case 'ArrowUp':
e.preventDefault();
p.setVolume?.(Math.min(100, (p.getVolume?.() ?? 100) + 10));
break;
case 'ArrowDown':
e.preventDefault();
p.setVolume?.(Math.max(0, (p.getVolume?.() ?? 100) - 10));
break;
case 'f':
case 'F':
e.preventDefault();
p.requestFullScreen?.();
break;
case 'm':
case 'M':
e.preventDefault();
p.mute?.();
break;
}
}
watch(() => props.url, (newVal) => {
if (newVal) {
dispose();
} catch (error) { }
loading.value = true;
nextTick(() => createPlayer(newVal, props.cover || '/long/long.svg'));
}
});
onMounted(() => {
nextTick(() => {
initPlayer();
playerWrapperRef.value?.focus?.();
});
});
onBeforeUnmount(() => {
dispose();
});
// //
// const saveTime = function (memoryVideo: string, currentTime: string) {
// localStorage.setItem(memoryVideo, currentTime);
// };
// //
// const getTime = function (memoryVideo: string): string | null {
// return localStorage.getItem(memoryVideo);
// };
</script>
<style scoped></style>
<style scoped lang="scss">
.player-wrapper {
position: relative;
outline: none;
&:focus {
outline: none;
}
}
.title-overlay {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 15;
pointer-events: none;
.title-text {
padding: 8px 20px;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 16px;
border-radius: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 10;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #ff8c42;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -11,21 +11,18 @@
<!-- 绘本展示 -->
<Transition name="content-fade" mode="out-in">
<div v-if="currentResourceType === 'ebook'" key="ebook" class="content-viewer">
<EbookViewer
:pages="ebookPages"
:current-page="currentEbookPage"
:audio-url="syncAudioUrl"
:auto-play="autoPlayAudio"
@page-change="handlePageChange"
@toggle-audio-sync="toggleAudioSync"
/>
<EbookViewer :pages="ebookPages" :current-page="currentEbookPage" :audio-url="syncAudioUrl"
:auto-play="autoPlayAudio" @page-change="handlePageChange" @toggle-audio-sync="toggleAudioSync" />
</div>
<!-- 视频播放 -->
<div v-else-if="currentResourceType === 'video'" key="video" class="content-viewer">
<VideoPlayer
:src="currentResourceUrl"
<Player
v-if="currentResourceUrl"
:key="currentResourceUrl"
:url="currentResourceUrl"
:title="currentResourceName"
:no-page="true"
@ended="handleMediaEnded"
/>
</div>
@ -40,40 +37,36 @@
<!-- 音频播放 -->
<div v-else-if="currentResourceType === 'audio'" key="audio" class="content-viewer">
<AudioPlayer
:src="currentResourceUrl"
:title="currentResourceName"
:background-image="backgroundImageUrl"
@ended="handleMediaEnded"
/>
<AudioPlayer :src="currentResourceUrl" :title="currentResourceName" :background-image="backgroundImageUrl"
@ended="handleMediaEnded" />
</div>
<!-- PPT/挂图展示 -->
<div v-else-if="currentResourceType === 'ppt' || currentResourceType === 'poster'" key="slides" class="content-viewer">
<SlidesViewer
<div v-else-if="currentResourceType === 'ppt'" key="ppt" class="content-viewer">
<!-- <SlidesViewer
:pages="slidesPages"
:current-page="currentSlidePage"
:type="currentResourceType"
@page-change="handleSlideChange"
/>
/> -->
<WebOffice v-if="currentResourceUrl" :key="currentResourceUrl" :url="currentResourceUrl"
:file-name="currentResourceName" :no-page="true" />
</div>
<!-- 文档展示 (PDF等) -->
<!-- PPT/挂图展示 -->
<div v-else-if="currentResourceType === 'poster'" key="slides" class="content-viewer">
<SlidesViewer :pages="slidesPages" :current-page="currentSlidePage" :type="currentResourceType"
@page-change="handleSlideChange" />
</div>
<!-- 文档展示 (PDF/Word/Excel 使用 WebOffice) -->
<div v-else-if="currentResourceType === 'document'" key="document" class="content-viewer">
<SlidesViewer
:pages="[currentResourceUrl]"
:current-page="0"
type="pdf"
/>
<WebOffice v-if="currentResourceUrl" :key="currentResourceUrl" :url="currentResourceUrl"
:file-name="currentResourceName" :no-page="true" />
</div>
<!-- 课程资源展示 (从底部点击的PPT/挂图) -->
<div v-else-if="showingResource" key="resource" class="content-viewer">
<SlidesViewer
:pages="[showingResource.url]"
:current-page="0"
:type="(showingResource.type as 'ppt' | 'poster')"
/>
<SlidesViewer :pages="[showingResource.url]" :current-page="0"
:type="(showingResource.type as 'ppt' | 'poster')" />
<div class="resource-header">
<span class="resource-title">{{ showingResource.name }}</span>
<button class="close-resource-btn" @click="showingResource = null">
@ -102,13 +95,9 @@
<span>教学资源</span>
</div>
<div class="resources-track">
<button
v-for="resource in allLessonResources"
:key="resource.id"
class="resource-chip"
<button v-for="resource in allLessonResources" :key="resource.id" class="resource-chip"
:class="{ active: currentResourceIndex === resource.index }"
@click.stop="handleResourceByIndex(resource.index)"
>
@click.stop="handleResourceByIndex(resource.index)">
<component :is="getResourceIcon(resource.type)" :size="24" :stroke-width="2.5" />
<span>{{ resource.name }}</span>
</button>
@ -133,12 +122,8 @@
<span>延伸活动</span>
</div>
<div class="activities-track">
<button
v-for="activity in activities"
:key="activity.id"
class="activity-chip"
@click.stop="handleActivityClick(activity)"
>
<button v-for="activity in activities" :key="activity.id" class="activity-chip"
@click.stop="handleActivityClick(activity)">
<component :is="getActivityIcon(activity.activityType)" :size="22" :stroke-width="2.5" />
<span>{{ activity.name }}</span>
</button>
@ -151,7 +136,8 @@
<ChevronLeft :size="28" :stroke-width="3" />
<span>上一个</span>
</button>
<button class="ctrl-btn primary" :disabled="currentResourceIndex >= allLessonResources.length - 1" @click.stop="nextResource">
<button class="ctrl-btn primary" :disabled="currentResourceIndex >= allLessonResources.length - 1"
@click.stop="nextResource">
<span>下一个</span>
<ChevronRight :size="28" :stroke-width="3" />
</button>
@ -163,18 +149,14 @@
</div>
<!-- 延伸活动弹窗 -->
<a-modal
v-model:open="activityModalVisible"
:title="selectedActivity?.name"
:footer="null"
width="90%"
class="activity-modal"
@cancel="activityModalVisible = false"
>
<a-modal v-model:open="activityModalVisible" :title="selectedActivity?.name" :footer="null" width="90%"
class="activity-modal" @cancel="activityModalVisible = false">
<div v-if="selectedActivity" class="activity-content">
<div v-if="activityResourceUrl" class="activity-media">
<video v-if="activityResourceType === 'video'" :src="activityResourceUrl" controls style="width: 100%; max-height: 50vh;"></video>
<img v-else-if="activityResourceType === 'image'" :src="activityResourceUrl" style="width: 100%; max-height: 50vh; object-fit: contain;">
<video v-if="activityResourceType === 'video'" :src="activityResourceUrl" controls
style="width: 100%; max-height: 50vh;"></video>
<img v-else-if="activityResourceType === 'image'" :src="activityResourceUrl"
style="width: 100%; max-height: 50vh; object-fit: contain;">
</div>
<div class="activity-info">
<div class="info-item" v-if="selectedActivity.objectives">
@ -224,6 +206,8 @@ import {
Lightbulb,
} from 'lucide-vue-next';
import EbookViewer from './viewers/EbookViewer.vue';
import Player from '@/views/office/player.vue';
import WebOffice from '@/views/office/WebOffice.vue';
import VideoPlayer from './viewers/VideoPlayer.vue';
import AudioPlayer from './viewers/AudioPlayer.vue';
import SlidesViewer from './viewers/SlidesViewer.vue';
@ -734,10 +718,10 @@ const loadCurrentStepResources = () => {
if (resource) {
currentResourceType.value = resource.type === '电子绘本' ? 'ebook' :
resource.type === '音频' ? 'audio' :
resource.type === '视频' ? 'video' :
resource.type === 'PPT课件' ? 'ppt' :
resource.type === '教学挂图' ? 'poster' : '';
resource.type === '音频' ? 'audio' :
resource.type === '视频' ? 'video' :
resource.type === 'PPT课件' ? 'ppt' :
resource.type === '教学挂图' ? 'poster' : '';
currentResourceUrl.value = resource.url ? getFileUrl(resource.url) : '';
currentResourceName.value = resource.name;
@ -889,7 +873,7 @@ const handlePageChange = (page: number) => {
currentEbookPage.value = page;
};
const handleSlideChange = (page: number) => { currentSlidePage.value = page; };
const handleMediaEnded = () => {};
const handleMediaEnded = () => { };
const toggleAudioSync = () => { autoPlayAudio.value = !autoPlayAudio.value; };
const handleDocumentPageChange = (page: number) => {
@ -1109,8 +1093,15 @@ onUnmounted(() => {
}
@keyframes float {
0%, 100% { transform: translateY(0) translateX(0); }
50% { transform: translateY(-20px) translateX(10px); }
0%,
100% {
transform: translateY(0) translateX(0);
}
50% {
transform: translateY(-20px) translateX(10px);
}
}
//
@ -1135,12 +1126,15 @@ onUnmounted(() => {
opacity: 0;
transform: scale(0.3);
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
@ -1153,6 +1147,7 @@ onUnmounted(() => {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(2.5);
opacity: 0;
@ -1161,15 +1156,32 @@ onUnmounted(() => {
//
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-5deg);
}
75% {
transform: rotate(5deg);
}
}
//
@keyframes sparkle {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.kids-content {
@ -1184,6 +1196,7 @@ onUnmounted(() => {
}
.content-viewer {
position: relative;
width: 100%;
height: 100%;
display: flex;
@ -1232,7 +1245,7 @@ onUnmounted(() => {
font-weight: 600;
margin-bottom: 12px;
color: #FF8C42;
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.empty-hint {
@ -1283,7 +1296,9 @@ onUnmounted(() => {
overflow-x: auto;
padding-bottom: 8px;
&::-webkit-scrollbar { display: none; }
&::-webkit-scrollbar {
display: none;
}
}
.step-group {
@ -1320,7 +1335,7 @@ onUnmounted(() => {
white-space: nowrap;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&:hover {
background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);
@ -1369,8 +1384,15 @@ onUnmounted(() => {
}
@keyframes pulse {
0%, 100% { box-shadow: 0 4px 16px rgba(255, 111, 0, 0.4); }
50% { box-shadow: 0 6px 24px rgba(255, 111, 0, 0.6); }
0%,
100% {
box-shadow: 0 4px 16px rgba(255, 111, 0, 0.4);
}
50% {
box-shadow: 0 6px 24px rgba(255, 111, 0, 0.6);
}
}
.step-resources {
@ -1395,7 +1417,7 @@ onUnmounted(() => {
white-space: nowrap;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
background: linear-gradient(135deg, #BBDEFB 0%, #90CAF9 100%);
@ -1421,7 +1443,7 @@ onUnmounted(() => {
background: linear-gradient(90deg, #FFE0B2 0%, #FFCCBC 100%);
border-radius: 12px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.progress-fill {
@ -1545,7 +1567,9 @@ onUnmounted(() => {
padding-bottom: 8px;
flex-wrap: wrap;
&::-webkit-scrollbar { display: none; }
&::-webkit-scrollbar {
display: none;
}
}
.resource-chip {
@ -1642,13 +1666,13 @@ onUnmounted(() => {
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-height: 60px;
&:hover:not(:disabled) {
background: linear-gradient(135deg, #F5F5F5 0%, #EEEEEE 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
&:active:not(:disabled) {
@ -1693,7 +1717,7 @@ onUnmounted(() => {
:deep(.ant-modal-content) {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
:deep(.ant-modal-header) {
@ -1741,7 +1765,7 @@ onUnmounted(() => {
border-radius: 20px;
overflow: hidden;
background: white;
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border: 3px solid #FFE0B2;
}
@ -1751,7 +1775,7 @@ onUnmounted(() => {
padding: 20px 24px;
background: white;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 2px solid #FFE0B2;
.info-label {

View File

@ -1,30 +1,14 @@
<template>
<div
class="video-player"
ref="playerRef"
:class="{
'is-fullscreen': isFullscreen,
'is-web-fullscreen': isWebFullscreen,
'controls-visible': showControls || !isPlaying
}"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div class="video-player" ref="playerRef" :class="{
'is-fullscreen': isFullscreen,
'is-web-fullscreen': isWebFullscreen,
'controls-visible': showControls || !isPlaying
}" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave">
<!-- 视频容器 -->
<div class="video-container" @click="togglePlay">
<video
ref="videoRef"
:src="src"
:poster="posterUrl"
class="video-element"
@loadedmetadata="onLoaded"
@timeupdate="onTimeUpdate"
@ended="onEnded"
@play="isPlaying = true"
@pause="isPlaying = false"
@waiting="loading = true"
@canplay="loading = false"
/>
<video ref="videoRef" :src="src" :poster="posterUrl" class="video-element" @loadedmetadata="onLoaded"
@timeupdate="onTimeUpdate" @ended="onEnded" @play="isPlaying = true" @pause="isPlaying = false"
@waiting="loading = true" @canplay="loading = false" />
</div>
<!-- 加载中 -->
@ -86,13 +70,8 @@
{{ playbackRate }}x
</button>
<div class="speed-menu" v-if="showSpeedMenu">
<div
v-for="speed in speedOptions"
:key="speed"
class="speed-option"
:class="{ active: playbackRate === speed }"
@click="setSpeed(speed)"
>
<div v-for="speed in speedOptions" :key="speed" class="speed-option"
:class="{ active: playbackRate === speed }" @click="setSpeed(speed)">
{{ speed }}x
</div>
</div>
@ -106,35 +85,18 @@
<VolumeX v-else :size="20" />
</button>
<div class="volume-slider-wrapper">
<input
type="range"
class="volume-slider"
:value="volume"
min="0"
max="1"
step="0.05"
@input="changeVolume"
/>
<input type="range" class="volume-slider" :value="volume" min="0" max="1" step="0.05"
@input="changeVolume" />
</div>
</div>
<!-- 循环 -->
<button
class="ctrl-btn"
:class="{ active: isLooping }"
@click="toggleLoop"
title="循环播放"
>
<button class="ctrl-btn" :class="{ active: isLooping }" @click="toggleLoop" title="循环播放">
<Repeat :size="20" />
</button>
<!-- 画中画 -->
<button
v-if="supportsPiP"
class="ctrl-btn"
@click="togglePiP"
title="画中画"
>
<button v-if="supportsPiP" class="ctrl-btn" @click="togglePiP" title="画中画">
<PictureInPicture2 :size="20" />
</button>
@ -563,7 +525,9 @@ onUnmounted(() => {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.play-overlay {