新增: 分享链接闭环——OSS 静态发布与观众查看器

This commit is contained in:
lxy
2026-08-28 00:26:09 +08:00
parent daaccb8ad7
commit eca5b11ea4
12 changed files with 469 additions and 59 deletions
+217
View File
@@ -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()
}
/* ---------- 页码 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"
/>
</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>