web_ppt / frontend /src /views /PublicViewer.vue
CatPtain's picture
Upload 168 files
7d93f04 verified
<template>
<div class="public-viewer">
<FullscreenSpin v-if="loading" tip="正在加载演示文稿..." loading :mask="false" />
<div v-else-if="error" class="error-container">
<div class="error-message">
<h3>{{ error }}</h3>
<p>请检查分享链接是否正确或联系分享者</p>
</div>
</div>
<div v-else class="presentation-container">
<!-- 演示文稿标题 -->
<div class="presentation-header">
<h1>{{ presentationTitle }}</h1>
<div class="controls">
<button @click="toggleFullscreen" class="control-btn">
{{ isFullscreen ? '退出全屏' : '全屏查看' }}
</button>
<button @click="startSlideshow" class="control-btn primary">
开始演示
</button>
</div>
</div>
<!-- 幻灯片预览 -->
<div class="slides-container" ref="slidesContainer">
<div
v-for="(slide, index) in slides"
:key="slide.id"
class="slide-preview"
:class="{ active: currentSlideIndex === index }"
@click="currentSlideIndex = index"
>
<div class="slide-number">{{ index + 1 }}</div>
<div class="slide-content">
<SlideThumbnail :slide="slide" />
</div>
</div>
</div>
<!-- 当前幻灯片显示 -->
<div class="current-slide-container">
<div class="slide-navigation">
<button
@click="previousSlide"
:disabled="currentSlideIndex === 0"
class="nav-btn"
>
上一页
</button>
<span class="slide-counter">
{{ currentSlideIndex + 1 }} / {{ slides.length }}
</span>
<button
@click="nextSlide"
:disabled="currentSlideIndex === slides.length - 1"
class="nav-btn"
>
下一页
</button>
</div>
<div class="current-slide" ref="currentSlideRef">
<Slide
v-if="currentSlide"
:slide="currentSlide"
:editable="false"
class="slide-display"
/>
</div>
</div>
</div>
<!-- 全屏演示模式 -->
<div v-if="isSlideshow" class="slideshow-mode" @keydown="handleKeydown">
<div class="slideshow-container">
<Slide
v-if="currentSlide"
:slide="currentSlide"
:editable="false"
class="slideshow-slide"
/>
<div class="slideshow-controls">
<button @click="exitSlideshow" class="exit-btn">退出演示</button>
<div class="slideshow-navigation">
<button @click="previousSlide" :disabled="currentSlideIndex === 0"></button>
<span>{{ currentSlideIndex + 1 }} / {{ slides.length }}</span>
<button @click="nextSlide" :disabled="currentSlideIndex === slides.length - 1"></button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useMessage } from '@/hooks/useMessage'
import api from '@/services'
import type { Slide as SlideType } from '@/types/slides'
import FullscreenSpin from '@/components/FullscreenSpin.vue'
import Slide from '@/views/components/Slide/index.vue'
import SlideThumbnail from '@/views/components/SlideThumbnail/index.vue'
const { message } = useMessage()
const loading = ref(true)
const error = ref('')
const slides = ref<SlideType[]>([])
const presentationTitle = ref('')
const currentSlideIndex = ref(0)
const isFullscreen = ref(false)
const isSlideshow = ref(false)
const slidesContainer = ref<HTMLElement>()
const currentSlideRef = ref<HTMLElement>()
const currentSlide = computed(() => slides.value[currentSlideIndex.value])
// 从URL获取分享ID
const getShareIdFromUrl = () => {
const url = window.location.href
const match = url.match(/\/public\/view\/([^/?]+)/)
return match ? match[1] : null
}
// 加载公共演示文稿
const loadPublicPresentation = async () => {
try {
loading.value = true
const shareId = getShareIdFromUrl()
if (!shareId) {
error.value = '无效的分享链接'
return
}
const response = await api.get(`/api/public/presentation/${shareId}`)
if (response.data.success) {
slides.value = response.data.slides
presentationTitle.value = response.data.title || '未命名演示文稿'
} else {
error.value = response.data.message || '加载失败'
}
} catch (err) {
console.error('加载公共演示文稿失败:', err)
error.value = '加载演示文稿时出错,请稍后重试'
} finally {
loading.value = false
}
}
// 幻灯片导航
const previousSlide = () => {
if (currentSlideIndex.value > 0) {
currentSlideIndex.value--
}
}
const nextSlide = () => {
if (currentSlideIndex.value < slides.value.length - 1) {
currentSlideIndex.value++
}
}
// 全屏功能
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
currentSlideRef.value?.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 演示模式
const startSlideshow = () => {
isSlideshow.value = true
currentSlideIndex.value = 0
document.addEventListener('keydown', handleKeydown)
}
const exitSlideshow = () => {
isSlideshow.value = false
document.removeEventListener('keydown', handleKeydown)
}
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
previousSlide()
break
case 'ArrowRight':
case 'ArrowDown':
case ' ':
nextSlide()
break
case 'Escape':
exitSlideshow()
break
}
}
// 全屏状态监听
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
onMounted(() => {
loadPublicPresentation()
document.addEventListener('fullscreenchange', handleFullscreenChange)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
})
</script>
<style lang="scss" scoped>
.public-viewer {
width: 100%;
height: 100vh;
background: #f5f5f5;
}
.error-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
.error-message {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
h3 {
color: #ff4757;
margin-bottom: 1rem;
}
p {
color: #666;
}
}
}
.presentation-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.presentation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: white;
border-bottom: 1px solid #e0e0e0;
h1 {
margin: 0;
color: #333;
font-size: 1.5rem;
}
.controls {
display: flex;
gap: 1rem;
}
.control-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f8f9fa;
}
&.primary {
background: #007bff;
color: white;
border-color: #007bff;
&:hover {
background: #0056b3;
}
}
}
}
.slides-container {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
overflow-y: auto;
max-height: 200px;
}
.slide-preview {
position: relative;
width: 200px;
height: 150px;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: white;
&:hover {
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
}
&.active {
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.2);
}
.slide-number {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
z-index: 10;
}
.slide-content {
width: 100%;
height: 100%;
padding: 8px;
}
}
.current-slide-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
background: white;
}
.slide-navigation {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
.nav-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #f8f9fa;
}
}
.slide-counter {
font-weight: 500;
color: #666;
}
}
.current-slide {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
.slide-display {
max-width: 90%;
max-height: 90%;
}
}
.slideshow-mode {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: black;
z-index: 9999;
}
.slideshow-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.slideshow-slide {
max-width: 95%;
max-height: 95%;
}
.slideshow-controls {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2rem;
.exit-btn {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.slideshow-navigation {
display: flex;
align-items: center;
gap: 1rem;
color: white;
button {
padding: 0.5rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
}
@media (max-width: 768px) {
.presentation-header {
flex-direction: column;
gap: 1rem;
.controls {
width: 100%;
justify-content: center;
}
}
.slides-container {
max-height: 120px;
}
.slide-preview {
width: 120px;
height: 90px;
}
}
</style>