Files
u-ppt/src/viewer/ViewerApp.vue
T

220 lines
6.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =====================================================================
ViewerApp.vue 分享查看器观众版独立入口
数据源分享 JSON不连 store),仅静态渲染当前页
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Deck, Slide } from '../core/types'
import { parseShareHash, shareJsonUrlOf } from '../core/share'
import { resolveBg, registerBgThemeGetter } from '../core/bg'
import { CANVAS_W, CANVAS_H } from '../core/sample'
import ElementView from '../components/editor/ElementView.vue'
import ViewerAnnotations from './ViewerAnnotations.vue'
type Status = 'loading' | 'ready' | 'error' | 'invalid'
const status = ref<Status>('loading')
const errMsg = ref('')
const deck = ref<Deck | null>(null)
const index = ref(0)
const scale = ref(1)
const idle = ref(false)
const slides = computed<Slide[]>(() => deck.value?.slides ?? [])
const total = computed(() => slides.value.length)
const currentSlide = computed<Slide | null>(() => {
const arr = slides.value
if (!arr.length) return null
return arr[Math.max(0, Math.min(index.value, arr.length - 1))]
})
/* ---------- 加载分享 JSON ---------- */
onMounted(async () => {
const parsed = parseShareHash()
if (!parsed) {
status.value = 'invalid'
return
}
try {
// 优先用链接内嵌的 JSON 地址(观众零配置);旧格式链接回退本地配置反推
const jsonUrl = parsed.jsonUrl || shareJsonUrlOf(parsed.shareId)
const res = await fetch(jsonUrl)
if (!res.ok) throw new Error('HTTP ' + res.status)
deck.value = JSON.parse(await res.text()) as Deck
if (!deck.value?.slides?.length) throw new Error('分享内容为空')
// 注入 deck 自带主题,让 resolveBg 按分享内容的主题解析背景/元素色
registerBgThemeGetter(() => String(deck.value?.theme || 'indigo'))
status.value = 'ready'
fit()
} catch (e: any) {
errMsg.value = e?.message || String(e)
status.value = 'error'
}
})
/* ---------- 缩放适配 ---------- */
function fit() {
scale.value = Math.min(window.innerWidth / CANVAS_W, window.innerHeight / CANVAS_H)
}
/* ---------- 翻页 ---------- */
function next() {
if (index.value < total.value - 1) index.value++
wake()
}
function prev() {
if (index.value > 0) index.value--
wake()
}
/* ---------- 键盘 ---------- */
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown':
e.preventDefault(); next(); break
case 'ArrowLeft': case 'PageUp':
e.preventDefault(); prev(); break
}
}
/* ---------- 点击:左半屏上一页 / 右半屏下一页 ---------- */
function onClick(e: MouseEvent) {
const stage = stageEl.value
if (!stage) return
const rect = stage.getBoundingClientRect()
if (e.clientX - rect.left > rect.width / 2) next()
else prev()
}
/* ---------- 触摸滑动 ---------- */
let touchX = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
if (dx > 40) prev()
else if (dx < -40) next()
}
/* ---------- 页码 HUD3 秒无操作淡出 ---------- */
let idleTimer: ReturnType<typeof setTimeout> | null = null
function wake() {
idle.value = false
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => { idle.value = true }, 3000)
}
function bindEvents() {
document.addEventListener('keydown', onKey)
window.addEventListener('resize', fit)
}
function unbindEvents() {
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', fit)
if (idleTimer) clearTimeout(idleTimer)
}
const stageEl = ref<HTMLElement | null>(null)
onMounted(bindEvents)
onUnmounted(unbindEvents)
</script>
<template>
<!-- 无效链接 -->
<div v-if="status === 'invalid'" class="viewer-state">
<div class="viewer-state-title">链接无效</div>
</div>
<!-- 加载失败 -->
<div v-else-if="status === 'error'" class="viewer-state">
<div class="viewer-state-title">加载失败链接可能已失效</div>
<div v-if="errMsg" class="viewer-state-detail">{{ errMsg }}</div>
</div>
<!-- 加载中 -->
<div v-else-if="status === 'loading'" class="viewer-state">
<div class="viewer-spinner"></div>
<div class="viewer-state-title">加载中</div>
</div>
<!-- 渲染 -->
<div v-else class="viewer-stage" :class="{ idle }" ref="stageEl" @click="onClick" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd" @mousemove="wake">
<!-- 沉浸背景当前页主色放大铺满 + 模糊消除黑边手机竖屏尤甚 -->
<div class="viewer-backdrop" :style="{ background: resolveBg(currentSlide?.background || 'bg') }"></div>
<div class="slide-layer" :style="{ transform: 'scale(' + scale + ')' }">
<div v-if="currentSlide" class="slide-inner" :style="{ background: resolveBg(currentSlide.background) }">
<ElementView
v-for="el in currentSlide.elements"
:key="el.id"
:el="el"
:bg="currentSlide.background"
/>
<ViewerAnnotations :slide="currentSlide" />
</div>
</div>
<div class="viewer-hud">{{ (index + 1) + ' / ' + total }}</div>
</div>
</template>
<style scoped>
.viewer-state {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-secondary, #888);
font-size: 15px;
user-select: none;
}
.viewer-state-title { font-weight: 500; }
.viewer-state-detail { font-size: 12px; opacity: 0.6; }
.viewer-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(128, 128, 128, 0.25);
border-top-color: var(--accent, #5b8def);
border-radius: 50%;
animation: viewer-spin 0.9s linear infinite;
}
@keyframes viewer-spin { to { transform: rotate(360deg); } }
.viewer-stage {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
cursor: pointer;
}
/* 沉浸背景层:幻灯片背景色的同色柔和延伸,不做明暗/饱和处理,过渡平缓 */
.viewer-backdrop {
position: absolute;
inset: -40px;
filter: blur(48px);
}
.viewer-backdrop + .slide-layer { box-shadow: 0 12px 40px rgba(0, 0, 0, .25); }
.viewer-hud {
position: fixed;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 12px;
user-select: none;
pointer-events: none;
transition: opacity 0.5s;
}
.viewer-stage.idle .viewer-hud { opacity: 0; }
</style>