新增: 分享链接闭环——OSS 静态发布与观众查看器
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
<!-- =====================================================================
|
||||
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'
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/* ---------- 页码 HUD:3 秒无操作淡出 ---------- */
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
Reference in New Issue
Block a user