Files
hunli/vite-tailwindcss/src/views/hb/index.vue

1078 lines
38 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.
<template>
<div class="hb-root" :data-hb-theme="cfg.posterTheme || 'redgold'">
<div class="hb-bg-decor" aria-hidden="true">
<span class="hb-bg-xi hb-bg-xi-1">囍</span>
<span class="hb-bg-xi hb-bg-xi-2">囍</span>
<span class="hb-bg-xi hb-bg-xi-3">囍</span>
<span class="hb-bg-xi hb-bg-xi-4">囍</span>
<span class="hb-bg-xi hb-bg-xi-5">囍</span>
<span class="hb-bg-xi hb-bg-xi-6">囍</span>
<svg class="hb-bg-knot hb-bg-knot-1" viewBox="0 0 64 80" fill="none"><use href="#hb-icon-knot" /></svg>
<svg class="hb-bg-knot hb-bg-knot-2" viewBox="0 0 64 80" fill="none"><use href="#hb-icon-knot" /></svg>
<svg class="hb-bg-knot hb-bg-knot-3" viewBox="0 0 64 80" fill="none"><use href="#hb-icon-knot" /></svg>
<svg class="hb-bg-knot hb-bg-knot-4" viewBox="0 0 64 80" fill="none"><use href="#hb-icon-knot" /></svg>
<svg class="hb-bg-lantern hb-bg-lantern-1" viewBox="0 0 48 72" fill="none"><use href="#hb-icon-lantern" /></svg>
<svg class="hb-bg-lantern hb-bg-lantern-2" viewBox="0 0 48 72" fill="none"><use href="#hb-icon-lantern" /></svg>
<i class="hb-bg-spark hb-bg-spark-1"></i>
<i class="hb-bg-spark hb-bg-spark-2"></i>
<i class="hb-bg-spark hb-bg-spark-3"></i>
<i class="hb-bg-spark hb-bg-spark-4"></i>
</div>
<svg class="hb-sprite" aria-hidden="true">
<symbol id="hb-icon-knot" viewBox="0 0 64 80">
<!-- 可识别中国结:顶环 + 盘长结体 + 流苏 -->
<path d="M32 4c0 0-8 4-8 12 0 4 3 7 8 7s8-3 8-7c0-8-8-12-8-12z" stroke="currentColor" stroke-width="2.2" fill="none"/>
<path d="M32 16v6" stroke="currentColor" stroke-width="2"/>
<!-- 盘长结菱形交织 -->
<path d="M32 24 L44 32 L32 40 L20 32 Z" stroke="currentColor" stroke-width="2.4" fill="currentColor" fill-opacity="0.12"/>
<path d="M32 28 L40 32 L32 36 L24 32 Z" stroke="currentColor" stroke-width="1.8" fill="none"/>
<path d="M26 26 L38 38M38 26 L26 38" stroke="currentColor" stroke-width="1.6" opacity="0.85"/>
<circle cx="32" cy="32" r="3.2" fill="currentColor"/>
<!-- 两侧耳结 -->
<path d="M20 32c-6-2-10 2-8 8 2 4 6 4 8 2M44 32c6-2 10 2 8 8-2 4-6 4-8 2" stroke="currentColor" stroke-width="2" fill="none"/>
<!-- 下坠与流苏 -->
<path d="M32 40v8" stroke="currentColor" stroke-width="2"/>
<path d="M28 48h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M26 50c-1 8-2 14-4 22M32 50v24M38 50c1 8 2 14 4 22" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
<path d="M29 50c-0.5 7-1 13-2 20M35 50c0.5 7 1 13 2 20" stroke="currentColor" stroke-width="1.2" opacity="0.65" stroke-linecap="round"/>
</symbol>
<symbol id="hb-icon-lantern" viewBox="0 0 48 72">
<!-- 可识别红灯笼:挂绳 + 金顶 + 鼓形灯身 + 囍 + 流苏 -->
<path d="M24 2v5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<ellipse cx="24" cy="8" rx="3" ry="1.6" fill="currentColor"/>
<!-- 顶盖 -->
<path d="M14 11h20l-2 5H16l-2-5z" fill="currentColor"/>
<rect x="15" y="15" width="18" height="3" rx="1" fill="currentColor" opacity="0.9"/>
<!-- 灯身(鼓形) -->
<path
d="M12 20c-4 6-4 22 0 28 3 5 9 8 12 8s9-3 12-8c4-6 4-22 0-28-3-5-9-8-12-8s-9 3-12 8z"
fill="currentColor"
fill-opacity="0.28"
stroke="currentColor"
stroke-width="2.2"
/>
<!-- 横楞 -->
<path d="M9 28c5-2.5 25-2.5 30 0" stroke="currentColor" stroke-width="1.6" opacity="0.75"/>
<path d="M8 36h32" stroke="currentColor" stroke-width="1.8" opacity="0.85"/>
<path d="M9 44c5 2.5 25 2.5 30 0" stroke="currentColor" stroke-width="1.6" opacity="0.75"/>
<!-- 竖框 -->
<path d="M24 18v36" stroke="currentColor" stroke-width="1.2" opacity="0.35"/>
<!-- 中心囍印 -->
<circle cx="24" cy="36" r="7.5" fill="currentColor" fill-opacity="0.2" stroke="currentColor" stroke-width="1.4"/>
<path d="M20.2 32.2h3.2v2.2h-1.4v1.4h1.4v2.2h-3.2v-2.2h1.4v-1.4h-1.4zm4.4 0h3.2v2.2h-1.4v1.4h1.4v2.2h-3.2v-2.2h1.4v-1.4h-1.4zM20.2 38.2h3.2v2.2h-1.4v1.4h1.4v2.2h-3.2v-2.2h1.4v-1.4h-1.4zm4.4 0h3.2v2.2h-1.4v1.4h1.4v2.2h-3.2v-2.2h1.4v-1.4h-1.4z" fill="currentColor"/>
<!-- 底托 -->
<rect x="17" y="52" width="14" height="4" rx="1.2" fill="currentColor"/>
<path d="M24 56v5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<!-- 流苏 -->
<path d="M20 61c-1 4-2 7-3 10M24 61v11M28 61c1 4 2 7 3 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path d="M22 61c-0.5 3.5-1 6.5-1.5 9M26 61c0.5 3.5 1 6.5 1.5 9" stroke="currentColor" stroke-width="1.1" opacity="0.65" stroke-linecap="round"/>
</symbol>
<symbol id="hb-icon-cloud" viewBox="0 0 80 36">
<path d="M8 28c0-8 6-14 14-14 2-8 10-12 18-10 4-6 14-8 20-2 8-2 16 4 16 12 0 8-6 12-16 12H18C10 26 8 22 8 28z" stroke="currentColor" stroke-width="1.8" fill="currentColor" fill-opacity="0.12"/>
</symbol>
<symbol id="hb-icon-corner" viewBox="0 0 48 48">
<path d="M4 44V18c0-8 6-14 14-14h26" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M10 44V24c0-6 4-10 10-10h24" stroke="currentColor" stroke-width="1.2" opacity="0.55" fill="none"/>
<circle cx="18" cy="18" r="3" fill="currentColor"/>
<path d="M18 8c6 2 10 6 12 12" stroke="currentColor" stroke-width="1.2" opacity="0.7"/>
</symbol>
</svg>
<div v-if="phase === 'loading'" class="hb-loading" :class="`hb-loading--${cfg.loadingEffect}`">
<div v-if="cfg.loadingEffect === 'redSeal'" class="hb-seal">囍</div>
<div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="hb-shimmer">婚礼邀请函</div>
<div v-else class="hb-fade-dot"></div>
</div>
<!-- 等待揭幕:音乐失败需轻触;音乐成功也会在此停留片刻后自动淡出 -->
<div
v-else-if="phase === 'waiting'"
class="hb-waiting"
@click="openPoster"
>
<div v-if="cfg.loadingEffect === 'redSeal'" class="hb-seal">囍</div>
<div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="hb-shimmer">婚礼邀请函</div>
<div v-else class="hb-fade-dot"></div>
<div class="hb-open-hint"><span class="dot"></span>轻触打开请柬</div>
</div>
<div
v-if="phase === 'enter'"
class="hb-enter"
:class="[`hb-enter--${cfg.enterEffect}`, { 'is-done': enterLeaving }]"
>
<div class="hb-enter-panel hb-enter-left"></div>
<div class="hb-enter-panel hb-enter-right"></div>
<div class="hb-enter-veil"></div>
</div>
<div
v-if="cfg.enabled && (phase === 'content' || phase === 'enter')"
class="hb-main"
>
<div class="hb-poster-wrap">
<ClassicPoster
v-if="cfg.template === 'classic'"
:cfg="cfg"
:ready="contentVisible && phase === 'content'"
@invite="goInvite"
/>
<SplitPoster
v-else
:cfg="cfg"
:ready="contentVisible && phase === 'content'"
@invite="goInvite"
/>
</div>
</div>
<!-- PC:右下角固定;仅 ?qr=1 时展示二维码,保存海报时也会打进图里 -->
<aside
v-if="isPc && phase === 'content' && contentVisible && cfg.enabled"
class="hb-pc-tools"
>
<button type="button" class="hb-save-btn" :disabled="savingImg" @click="savePosterImage">
<i class="fa-solid fa-download"></i>
{{ savingImg ? '生成中…' : '保存海报' }}
</button>
<FestiveQrCard
v-if="showCaptureQr"
:url="qrTargetUrl"
:size="112"
:center-img="cfg.qrCenterImg"
compact
/>
</aside>
<button
v-if="hasMusic && (phase === 'waiting' || phase === 'enter' || phase === 'content')"
type="button"
class="hb-music-fab"
:class="{ 'is-playing': audio.isPlaying.value }"
:title="audio.isPlaying.value ? '暂停音乐' : '播放音乐'"
@click.stop="audio.toggle()"
>
<i class="fa-solid" :class="audio.isPlaying.value ? 'fa-pause' : 'fa-music'"></i>
</button>
<!-- 答谢函入口(婚礼后致谢页) -->
<button
v-if="cfg.enabled && cfg.showThanksEntry && phase === 'content' && contentVisible"
type="button"
class="hb-thanks-link"
@click="goThanks"
>
<i class="fa-solid fa-hand-holding-heart"></i>
{{ cfg.thanksEntryText || '婚礼答谢函' }}
</button>
<div v-if="!cfg.enabled && phase === 'content'" class="hb-disabled">
<p>海报暂未开放</p>
<button type="button" class="hb-cta-btn" @click="goInvite">前往请柬</button>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import { getConfig, getPosterConfig } from '@/api/wedding'
import { normalizePosterConfig, posterLayoutMeta, posterSideMeta, posterThemeMeta, resolvePosterLayout, resolvePosterSide } from '@/composables/posterDefaults'
import { renderFestiveQr } from '@/composables/renderFestiveQr'
import { drawXiSeal } from '@/composables/drawXiSeal'
import { useAudio } from '@/composables/useAudio'
import ClassicPoster from '@/views/hb/templates/ClassicPoster.vue'
import SplitPoster from '@/views/hb/templates/SplitPoster.vue'
import FestiveQrCard from '@/components/FestiveQrCard.vue'
const route = useRoute()
const router = useRouter()
const posterSide = computed(() => resolvePosterSide(route.meta.posterSide) || 1)
const posterLayout = computed(() => resolvePosterLayout(route.params.layout || 1))
const cfg = reactive(normalizePosterConfig(null, posterSide.value, posterLayout.value))
const phase = ref('loading')
const enterLeaving = ref(false)
const contentVisible = ref(false)
const savingImg = ref(false)
const isPc = ref(false)
/** 仅 ?qr=1 时:侧栏展示二维码,且保存海报打进图里 */
const showCaptureQr = computed(() => String(route.query.qr ?? '') === '1')
/** 二维码指向干净海报地址(去掉 qr 参数,避免扫开又带导出模式) */
const qrTargetUrl = computed(() => {
if (typeof window === 'undefined') return ''
try {
const u = new URL(window.location.href)
u.searchParams.delete('qr')
return u.toString()
} catch {
return window.location.href
}
})
const audio = useAudio()
const hasMusic = computed(() => !!String(cfg.musicUrl || '').trim())
/** 与请柬一致:有音乐且自动播放成功后延迟自动揭幕 */
const POSTER_AUTO_OPEN_MS = 3600
let posterOpening = false
let autoOpenTimer = null
let timers = []
function later(fn, ms) {
const id = setTimeout(fn, ms)
timers.push(id)
return id
}
function refreshPc() {
isPc.value = window.matchMedia('(min-width: 900px)').matches
}
function goInvite() {
const path = cfg.invitePath || '/'
if (path.startsWith('http')) window.location.href = path
else router.push(path)
}
function goThanks() {
const path = cfg.thanksPath || '/dx-xr'
if (path.startsWith('http')) window.location.href = path
else router.push(path)
}
function upsertMeta(attr, key, content) {
if (!content) return
let el = document.head.querySelector(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function applyPageMeta() {
const title = String(cfg.pageTitle || cfg.title || posterSideMeta(cfg.side).label || '').trim()
const desc = String(cfg.pageDescription || '').trim()
if (title) {
document.title = title
upsertMeta('property', 'og:title', title)
upsertMeta('name', 'twitter:title', title)
}
if (desc) {
upsertMeta('name', 'description', desc)
upsertMeta('property', 'og:description', desc)
upsertMeta('name', 'twitter:description', desc)
}
}
function parseObjectPosition(value) {
const parts = String(value || '50% 50%').trim().split(/\s+/)
const map = { left: 0, top: 0, center: 50, right: 100, bottom: 100 }
const toPct = (token, fallback) => {
if (token == null) return fallback
if (map[token] != null) return map[token]
const n = parseFloat(token)
return Number.isFinite(n) ? n : fallback
}
return {
x: toPct(parts[0], 50),
y: toPct(parts[1] ?? parts[0], 50),
}
}
/** 按 object-fit / object-position 把「屏幕上看到的那一块」画到 canvas */
function drawObjectFittedImage(ctx, img, cw, ch, fit, posX, posY) {
const nw = img.naturalWidth
const nh = img.naturalHeight
if (!nw || !nh || cw <= 0 || ch <= 0) return
const mode = fit === 'contain' ? 'contain' : 'cover'
const scale = mode === 'contain'
? Math.min(cw / nw, ch / nh)
: Math.max(cw / nw, ch / nh)
const dw = nw * scale
const dh = nh * scale
const dx = (cw - dw) * (posX / 100)
const dy = (ch - dh) * (posY / 100)
ctx.drawImage(img, 0, 0, nw, nh, dx, dy, dw, dh)
}
/**
* html2canvas 不尊重 object-fit/object-position,会把整张原图塞进框里。
* 截图前把 cover/contain 图烘焙成「当前可见裁切」,截完再还原。
*/
async function bakeObjectFitImages(root) {
const imgs = [...root.querySelectorAll('img')].filter((img) => {
const fit = getComputedStyle(img).objectFit
return fit === 'cover' || fit === 'contain'
})
if (!imgs.length) return () => {}
const loadCorsImage = (src) => new Promise((resolve, reject) => {
const im = new Image()
im.crossOrigin = 'anonymous'
im.onload = () => resolve(im)
im.onerror = () => reject(new Error('image load failed'))
im.src = src
})
const restores = []
for (const img of imgs) {
const rect = img.getBoundingClientRect()
const w = Math.max(1, Math.round(rect.width))
const h = Math.max(1, Math.round(rect.height))
const cs = getComputedStyle(img)
const { x: posX, y: posY } = parseObjectPosition(cs.objectPosition)
const fit = cs.objectFit === 'contain' ? 'contain' : 'cover'
const src = img.currentSrc || img.src
if (!src) continue
let source = img
if (!img.naturalWidth) {
try { source = await loadCorsImage(src) } catch { continue }
}
const bakeScale = Math.min(3, Math.max(2, window.devicePixelRatio || 2))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(w * bakeScale))
canvas.height = Math.max(1, Math.round(h * bakeScale))
const ctx = canvas.getContext('2d')
if (!ctx) continue
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.scale(bakeScale, bakeScale)
let dataUrl = ''
try {
drawObjectFittedImage(ctx, source, w, h, fit, posX, posY)
dataUrl = canvas.toDataURL('image/png')
} catch {
// 可能被跨域污染:改用带 CORS 的图重试
try {
source = await loadCorsImage(src)
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.scale(bakeScale, bakeScale)
drawObjectFittedImage(ctx, source, w, h, fit, posX, posY)
dataUrl = canvas.toDataURL('image/png')
} catch {
continue
}
}
if (!dataUrl) continue
const prev = {
src: img.getAttribute('src'),
objectFit: img.style.objectFit,
objectPosition: img.style.objectPosition,
width: img.style.width,
height: img.style.height,
}
img.setAttribute('src', dataUrl)
img.style.objectFit = 'fill'
img.style.objectPosition = '50% 50%'
img.style.width = `${w}px`
img.style.height = `${h}px`
restores.push(() => {
if (prev.src != null) img.setAttribute('src', prev.src)
else img.removeAttribute('src')
img.style.objectFit = prev.objectFit
img.style.objectPosition = prev.objectPosition
img.style.width = prev.width
img.style.height = prev.height
})
}
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
return () => restores.forEach((fn) => fn())
}
async function mountCaptureQr(host) {
const isSplit = host.classList.contains('sp-shell') || !!host.querySelector?.('.sp-card')
const target = isSplit
? (host.querySelector('.sp-left') || host)
: (host.querySelector('.hb-inner') || host)
// 显示尺寸适中;位图按 3× 渲染,避免 html2canvas 放大后糊成一片
const displaySize = isSplit ? 84 : 108
const renderSize = displaySize * 3
const badge = document.createElement('div')
badge.setAttribute('data-hb-capture-qr', '')
badge.className = isSplit ? 'hb-cq--split' : 'hb-cq--classic'
badge.innerHTML = `
<div class="hb-cq-title">扫码观喜</div>
<div class="hb-cq-frame">
<span class="hb-cq-c hb-cq-tl">囍</span>
<span class="hb-cq-c hb-cq-tr">囍</span>
<span class="hb-cq-c hb-cq-bl">囍</span>
<span class="hb-cq-c hb-cq-br">囍</span>
<canvas></canvas>
</div>
<div class="hb-cq-tip">微信长按识别二维码</div>
`
if (!isSplit) {
target.classList.add('hb-inner--capturing')
} else if (getComputedStyle(target).position === 'static') {
target.style.position = 'relative'
}
// 保存时先藏按钮,避免进图、也避免占位
const hiddenCtas = []
host.querySelectorAll('[data-html2canvas-ignore]').forEach((node) => {
hiddenCtas.push([node, node.style.display])
node.style.display = 'none'
})
target.appendChild(badge)
const qrCanvas = badge.querySelector('canvas')
await renderFestiveQr(qrCanvas, qrTargetUrl.value, {
size: renderSize,
centerImg: cfg.qrCenterImg,
})
// html2canvas 对 canvas 常二次缩放发糊:改用高清位图 img 再截
const qrImg = document.createElement('img')
qrImg.alt = 'qr'
qrImg.width = displaySize
qrImg.height = displaySize
qrImg.style.width = `${displaySize}px`
qrImg.style.height = `${displaySize}px`
qrImg.style.display = 'block'
qrImg.style.borderRadius = '3px'
qrImg.src = qrCanvas.toDataURL('image/png')
qrCanvas.replaceWith(qrImg)
await new Promise((r) => {
if (qrImg.complete) {
requestAnimationFrame(() => requestAnimationFrame(r))
return
}
qrImg.onload = () => requestAnimationFrame(() => requestAnimationFrame(r))
qrImg.onerror = () => r()
})
return () => {
badge.remove()
target.classList.remove('hb-inner--capturing')
hiddenCtas.forEach(([node, display]) => {
node.style.display = display
})
}
}
async function savePosterImage() {
const el = document.querySelector('[data-poster-capture]')
if (!el) return
savingImg.value = true
let cleanupQr = null
let cleanupBake = null
try {
cleanupBake = await bakeObjectFitImages(el)
if (showCaptureQr.value) {
cleanupQr = await mountCaptureQr(el)
}
// 导出前重绘囍印章位图,避免 html2canvas 对 CJK 文本错位
try { await document.fonts?.ready } catch { /* ignore */ }
el.querySelectorAll('canvas.sp-xi-mid, canvas.hb-xi-circle').forEach((c) => {
const isClassic = c.classList.contains('hb-xi-circle')
const theme = posterThemeMeta(cfg.posterTheme)
drawXiSeal(c, {
size: isClassic ? 22 : 28,
fontSize: isClassic ? 11 : 13,
color: theme.sealColor,
borderColor: theme.sealColor,
fill: isClassic ? theme.sealFill : 'rgba(0, 0, 0, 0.18)',
})
})
// 按元素实际宽度抬升倍率:目标约 1600px 宽,最高 3x,避免手机导出糊成一片
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
const scale = Math.min(3, Math.max(2.75, 1600 / baseW))
const canvas = await html2canvas(el, {
useCORS: true,
allowTaint: true,
backgroundColor: posterThemeMeta(cfg.posterTheme).shotBg,
scale,
logging: false,
imageTimeout: 20000,
removeContainer: true,
ignoreElements: (node) => !!node?.hasAttribute?.('data-html2canvas-ignore'),
})
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('导出失败'))),
'image/png',
)
})
const a = document.createElement('a')
a.download = `婚礼海报-${posterSideMeta(cfg.side).label}-${posterLayoutMeta(cfg.layout).label}-${Date.now()}.png`
a.href = URL.createObjectURL(blob)
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
} catch (e) {
console.error(e)
window.alert('保存失败,请稍后重试(跨域图片可能导致无法导出)')
} finally {
cleanupQr?.()
cleanupBake?.()
savingImg.value = false
}
}
/** @returns {Promise<boolean>} 无音乐视为成功;有音乐则返回是否自动播放成功 */
async function setupPosterMusic() {
const url = String(cfg.musicUrl || '').trim()
if (!url) return true
let track = { url, name: '背景音乐' }
try {
const res = await getConfig()
const list = Array.isArray(res.data?.musicList) ? res.data.musicList : []
const hit = list.find((t) => t && t.url === url)
if (hit) track = { url: hit.url, name: hit.name || '背景音乐', coverUrl: hit.coverUrl || '' }
} catch {
/* 仅用 poster 上的 url 亦可播放 */
}
audio.setTracks([track], url)
return audio.attemptAutoplay()
}
/** 执行海报淡出揭幕动画(enter → content) */
async function playPosterEnter() {
phase.value = 'enter'
contentVisible.value = false
enterLeaving.value = false
await nextTick()
const enterMs = { fadeUp: 700, scaleIn: 800, curtain: 1000 }[cfg.enterEffect] || 700
later(() => {
enterLeaving.value = true
contentVisible.value = true
}, 80)
await new Promise((r) => later(r, enterMs))
phase.value = 'content'
}
/** 打开海报:手势或自动;顺带在用户手势下再尝试播音乐 */
async function openPoster() {
if (posterOpening) return
if (phase.value !== 'waiting' && phase.value !== 'loading') return
posterOpening = true
clearTimeout(autoOpenTimer)
autoOpenTimer = null
if (hasMusic.value) audio.play()
await playPosterEnter()
}
async function runSequence() {
const loadMs = cfg.loadingEffect === 'fade' ? 700 : 1100
await new Promise((r) => later(r, loadMs))
if (!cfg.enabled) {
phase.value = 'content'
contentVisible.value = true
return
}
const musicOk = await setupPosterMusic()
if (hasMusic.value && !musicOk) {
// 与请柬一致:自动播放失败则停住,等待轻触后再揭幕
phase.value = 'waiting'
return
}
if (hasMusic.value && musicOk) {
// 有音乐且已播出:短暂停留后自动淡出揭幕
autoOpenTimer = later(() => openPoster(), POSTER_AUTO_OPEN_MS)
phase.value = 'waiting'
return
}
// 未配置音乐:直接揭幕
await openPoster()
}
onMounted(async () => {
refreshPc()
window.addEventListener('resize', refreshPc, { passive: true })
const side = posterSide.value
const layout = posterLayout.value
try {
const res = await getPosterConfig(side, layout)
const next = normalizePosterConfig(res.data || {}, side, layout)
next.layout = layout
Object.assign(cfg, next)
} catch {
const next = normalizePosterConfig(null, side, layout)
Object.assign(cfg, next)
}
applyPageMeta()
runSequence()
})
onUnmounted(() => {
timers.forEach(clearTimeout)
timers = []
clearTimeout(autoOpenTimer)
autoOpenTimer = null
window.removeEventListener('resize', refreshPc)
})
</script>
<style scoped>
/* ============================================================
海报风格模板(5 套皮肤):data-hb-theme 切换整组变量
- 面板红分三档 rgb(hi/mid/lo)保留原有明暗层次
- 所有模板(竖版/横版)共用这一组变量
============================================================ */
.hb-root,
.hb-root[data-hb-theme='redgold'] {
--hb-red: #9b0000;
--hb-red-deep: #7a0000;
--hb-red-bright: #c41e3a;
--hb-gold: #d4af37;
--hb-gold-deep: #a8892e;
--hb-gold-soft: #f0d78c;
--hb-cream: #f7e7c3;
--hb-ivory: #f7efe0;
--hb-gold-rgb: 212, 175, 55;
--hb-gold-soft-rgb: 240, 215, 140;
--hb-accent-rgb: 196, 30, 58;
--hb-cream-rgb: 243, 230, 200;
--hb-ivory-rgb: 247, 239, 224;
--hb-panel: #8b0000;
--hb-panel-deep: #5c0000;
--hb-photo-bg: #c23a2b;
--hb-panel-hi-rgb: 145, 5, 5;
--hb-panel-rgb: 116, 0, 0;
--hb-panel-lo-rgb: 85, 0, 0;
--hb-page-hi: #c20808;
--hb-page-mid: #b30000;
}
.hb-root[data-hb-theme='burgundy'] {
--hb-red: #6b1d30;
--hb-red-deep: #4a1220;
--hb-red-bright: #a8465c;
--hb-gold: #d9a05b;
--hb-gold-deep: #a87840;
--hb-gold-soft: #e2b98f;
--hb-cream: #ecd9c2;
--hb-ivory: #f2e8dc;
--hb-gold-rgb: 217, 160, 91;
--hb-gold-soft-rgb: 226, 185, 143;
--hb-accent-rgb: 168, 70, 92;
--hb-cream-rgb: 236, 217, 194;
--hb-ivory-rgb: 242, 232, 220;
--hb-panel: #5a1728;
--hb-panel-deep: #380d18;
--hb-photo-bg: #7d3040;
--hb-panel-hi-rgb: 108, 32, 50;
--hb-panel-rgb: 84, 22, 38;
--hb-panel-lo-rgb: 60, 14, 26;
--hb-page-hi: #7d2438;
--hb-page-mid: #5f1a2a;
}
.hb-root[data-hb-theme='navygold'] {
--hb-red: #1a2f52;
--hb-red-deep: #101f38;
--hb-red-bright: #4a6a9e;
--hb-gold: #d4b060;
--hb-gold-deep: #a3833c;
--hb-gold-soft: #e8cf96;
--hb-cream: #ecdfc0;
--hb-ivory: #f0ead8;
--hb-gold-rgb: 212, 176, 96;
--hb-gold-soft-rgb: 232, 207, 150;
--hb-accent-rgb: 74, 106, 158;
--hb-cream-rgb: 236, 223, 192;
--hb-ivory-rgb: 240, 234, 216;
--hb-panel: #16294a;
--hb-panel-deep: #0b1526;
--hb-photo-bg: #27436d;
--hb-panel-hi-rgb: 30, 52, 88;
--hb-panel-rgb: 20, 38, 66;
--hb-panel-lo-rgb: 12, 24, 44;
--hb-page-hi: #24406b;
--hb-page-mid: #1a3054;
}
.hb-root[data-hb-theme='inkjade'] {
--hb-red: #1d3a32;
--hb-red-deep: #132a24;
--hb-red-bright: #4a8a74;
--hb-gold: #c9b37e;
--hb-gold-deep: #97824f;
--hb-gold-soft: #d8c493;
--hb-cream: #e6dcc0;
--hb-ivory: #eee9d8;
--hb-gold-rgb: 201, 179, 126;
--hb-gold-soft-rgb: 216, 196, 147;
--hb-accent-rgb: 74, 138, 116;
--hb-cream-rgb: 230, 220, 192;
--hb-ivory-rgb: 238, 233, 216;
--hb-panel: #1a342d;
--hb-panel-deep: #0e1e19;
--hb-photo-bg: #2c4f44;
--hb-panel-hi-rgb: 34, 62, 53;
--hb-panel-rgb: 24, 46, 39;
--hb-panel-lo-rgb: 15, 30, 25;
--hb-page-hi: #2a4c40;
--hb-page-mid: #203b32;
}
.hb-root[data-hb-theme='rosewood'] {
--hb-red: #7d2c42;
--hb-red-deep: #5c1f30;
--hb-red-bright: #c05a74;
--hb-gold: #e0b487;
--hb-gold-deep: #b08a5c;
--hb-gold-soft: #f0d3b2;
--hb-cream: #f5e4d0;
--hb-ivory: #f8eee2;
--hb-gold-rgb: 224, 180, 135;
--hb-gold-soft-rgb: 240, 211, 178;
--hb-accent-rgb: 192, 90, 116;
--hb-cream-rgb: 245, 228, 208;
--hb-ivory-rgb: 248, 238, 226;
--hb-panel: #6e2538;
--hb-panel-deep: #451522;
--hb-photo-bg: #94445a;
--hb-panel-hi-rgb: 130, 46, 68;
--hb-panel-rgb: 100, 32, 50;
--hb-panel-lo-rgb: 72, 22, 35;
--hb-page-hi: #963c56;
--hb-page-mid: #762c42;
}
.hb-root {
position: relative;
min-height: 100dvh;
background:
radial-gradient(ellipse at 50% 18%, var(--hb-page-hi) 0%, transparent 42%),
radial-gradient(circle at 12% 80%, rgba(var(--hb-gold-rgb), 0.12), transparent 28%),
radial-gradient(circle at 88% 75%, rgba(var(--hb-gold-rgb), 0.1), transparent 26%),
radial-gradient(ellipse at 50% 20%, var(--hb-page-mid) 0%, var(--hb-red) 45%, var(--hb-red-deep) 100%);
color: var(--hb-gold);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
box-sizing: border-box;
overflow-x: hidden;
font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", serif;
}
.hb-main {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 960px;
}
.hb-poster-wrap {
display: flex;
justify-content: center;
min-width: 0;
width: 100%;
}
.hb-pc-tools {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 25;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
pointer-events: none;
}
.hb-pc-tools > * { pointer-events: auto; }
.hb-waiting {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
background: var(--hb-red-deep);
cursor: pointer;
}
.hb-open-hint {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--hb-gold-soft);
font-size: 13px;
letter-spacing: 0.26em;
animation: hb-hint-pulse 1.6s ease-in-out infinite;
}
.hb-open-hint .dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--hb-gold-soft);
box-shadow: 0 0 8px rgba(var(--hb-gold-soft-rgb), 0.7);
}
@keyframes hb-hint-pulse {
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
}
.hb-music-fab {
position: fixed;
left: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 30;
width: 44px;
height: 44px;
border-radius: 999px;
border: 1.5px solid rgba(var(--hb-gold-rgb), 0.85);
background: linear-gradient(160deg, rgba(var(--hb-gold-rgb), 0.42), rgba(var(--hb-panel-rgb), 0.82));
color: var(--hb-gold-soft);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(6px);
}
.hb-music-fab.is-playing {
animation: hb-music-spin 8s linear infinite;
}
.hb-music-fab.is-playing .fa-pause {
animation: none;
}
.hb-thanks-link {
position: fixed;
left: 50%;
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
transform: translateX(-50%);
z-index: 26;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 18px;
border-radius: 999px;
border: 1px solid rgba(var(--hb-gold-rgb), 0.6);
background: rgba(var(--hb-panel-rgb), 0.55);
color: var(--hb-gold-soft);
font-size: 12px;
letter-spacing: 0.18em;
cursor: pointer;
backdrop-filter: blur(6px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
}
.hb-thanks-link:hover {
filter: brightness(1.1);
}
@keyframes hb-music-spin {
from { filter: brightness(1); }
50% { filter: brightness(1.12); }
to { filter: brightness(1); }
}
.hb-save-btn {
appearance: none;
border: 1.5px solid var(--hb-gold);
background: linear-gradient(180deg, rgba(var(--hb-gold-rgb), 0.45), rgba(var(--hb-panel-hi-rgb), 0.72));
color: var(--hb-gold-soft);
padding: 10px 16px;
border-radius: 999px;
letter-spacing: 0.2em;
font-size: 13px;
cursor: pointer;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
backdrop-filter: blur(6px);
}
.hb-save-btn:disabled { opacity: 0.65; cursor: wait; }
.hb-save-btn:not(:disabled):hover {
filter: brightness(1.08);
}
.hb-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.hb-bg-decor { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.hb-bg-xi {
position: absolute; color: rgba(var(--hb-gold-soft-rgb), 0.14); font-weight: 700; line-height: 1;
user-select: none; animation: hb-pulse-soft 7s ease-in-out infinite;
}
.hb-bg-xi-1 { top: 6%; left: 4%; font-size: 56px; transform: rotate(-12deg); }
.hb-bg-xi-2 { top: 14%; right: 6%; font-size: 42px; transform: rotate(10deg); color: rgba(var(--hb-accent-rgb), 0.22); animation-delay: 1.2s; }
.hb-bg-xi-3 { bottom: 18%; left: 8%; font-size: 48px; transform: rotate(8deg); animation-delay: 2s; }
.hb-bg-xi-4 { bottom: 10%; right: 10%; font-size: 64px; transform: rotate(-8deg); color: rgba(var(--hb-accent-rgb), 0.18); animation-delay: 0.6s; }
.hb-bg-xi-5 { top: 42%; left: 2%; font-size: 36px; opacity: 0.8; animation-delay: 3s; }
.hb-bg-xi-6 { top: 48%; right: 3%; font-size: 40px; opacity: 0.75; animation-delay: 2.4s; }
.hb-bg-knot { position: absolute; color: rgba(var(--hb-gold-rgb), 0.38); width: 56px; height: 70px; animation: hb-float 10s ease-in-out infinite; }
.hb-bg-knot-1 { top: 7%; left: 12%; width: 52px; height: 66px; }
.hb-bg-knot-2 { top: 20%; right: 10%; width: 46px; height: 58px; color: rgba(var(--hb-gold-soft-rgb), 0.34); animation-delay: 1.8s; }
.hb-bg-knot-3 { bottom: 18%; left: 8%; width: 50px; height: 64px; animation-delay: 2.6s; }
.hb-bg-knot-4 { bottom: 10%; right: 12%; width: 58px; height: 72px; color: rgba(var(--hb-gold-soft-rgb), 0.3); animation-delay: 1s; }
.hb-bg-lantern { position: absolute; color: rgba(var(--hb-gold-soft-rgb), 0.48); width: 42px; height: 64px; animation: hb-swing 5.5s ease-in-out infinite; }
.hb-bg-lantern-1 { top: 3%; left: 20%; }
.hb-bg-lantern-2 { top: 4%; right: 18%; animation-delay: 1.4s; }
.hb-bg-spark {
position: absolute; width: 6px; height: 6px; border-radius: 50%;
background: radial-gradient(circle, var(--hb-gold-soft), transparent 70%);
box-shadow: 0 0 10px rgba(var(--hb-gold-soft-rgb), 0.55); animation: hb-twinkle 2.8s ease-in-out infinite;
}
.hb-bg-spark-1 { top: 18%; left: 30%; }
.hb-bg-spark-2 { top: 28%; right: 28%; animation-delay: 0.7s; }
.hb-bg-spark-3 { bottom: 30%; left: 26%; animation-delay: 1.3s; }
.hb-bg-spark-4 { bottom: 24%; right: 24%; animation-delay: 2s; }
.hb-loading {
position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
background: var(--hb-red-deep);
}
.hb-seal {
width: 88px; height: 88px; border-radius: 50%; border: 3px solid var(--hb-gold); color: var(--hb-gold);
display: flex; align-items: center; justify-content: center; font-size: 42px; font-weight: 700;
animation: hb-seal-pop 1s ease both; box-shadow: 0 0 0 6px rgba(var(--hb-gold-rgb), 0.15);
}
.hb-shimmer {
font-size: 28px; letter-spacing: 0.35em;
background: linear-gradient(90deg, var(--hb-gold-deep), var(--hb-gold-soft), var(--hb-gold-deep)); background-size: 200% 100%;
-webkit-background-clip: text; background-clip: text; color: transparent; animation: hb-shimmer 1.2s linear infinite;
}
.hb-fade-dot {
width: 12px; height: 12px; border-radius: 50%; background: var(--hb-gold);
animation: hb-fade-pulse 0.9s ease-in-out infinite;
}
.hb-enter { position: fixed; inset: 0; z-index: 30; pointer-events: none; }
.hb-enter--curtain .hb-enter-panel {
position: absolute; top: 0; bottom: 0; width: 50%;
background: linear-gradient(180deg, var(--hb-panel), var(--hb-panel-deep));
transition: transform 0.95s cubic-bezier(0.65, 0, 0.35, 1);
}
.hb-enter--curtain .hb-enter-left { left: 0; border-right: 1px solid rgba(var(--hb-gold-rgb), 0.35); }
.hb-enter--curtain .hb-enter-right { right: 0; border-left: 1px solid rgba(var(--hb-gold-rgb), 0.35); }
.hb-enter--curtain.is-done .hb-enter-left { transform: translateX(-105%); }
.hb-enter--curtain.is-done .hb-enter-right { transform: translateX(105%); }
.hb-enter--fadeUp .hb-enter-veil,
.hb-enter--scaleIn .hb-enter-veil {
position: absolute; inset: 0; background: var(--hb-red-deep);
transition: opacity 0.7s ease, transform 0.8s ease;
}
.hb-enter--fadeUp.is-done .hb-enter-veil { opacity: 0; transform: translateY(-24px); }
.hb-enter--scaleIn.is-done .hb-enter-veil { opacity: 0; transform: scale(1.08); }
.hb-disabled {
position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 16px;
background: rgba(var(--hb-panel-lo-rgb), 0.92); color: var(--hb-cream);
}
.hb-cta-btn {
appearance: none; border: 1px solid var(--hb-gold);
background: linear-gradient(180deg, rgba(var(--hb-gold-rgb), 0.22), rgba(var(--hb-gold-rgb), 0.08));
color: var(--hb-gold-soft); padding: 8px 22px; border-radius: 999px;
letter-spacing: 0.28em; font-size: 13px; cursor: pointer;
}
@keyframes hb-seal-pop {
0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
60% { opacity: 1; transform: scale(1.08) rotate(2deg); }
100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes hb-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
@keyframes hb-fade-pulse { 0%, 100% { opacity: 0.25; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.1); } }
@keyframes hb-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@keyframes hb-pulse-soft { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
@keyframes hb-swing { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }
@keyframes hb-twinkle { 0%, 100% { opacity: 0.25; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1.25); } }
</style>
<!-- 保存截图时注入的喜庆二维码(非 Vue 节点,需全局样式) -->
<style>
[data-hb-capture-qr] {
z-index: 30;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 8px 10px;
background:
radial-gradient(circle at 50% 0%, rgba(var(--hb-gold-soft-rgb), 0.28), transparent 55%),
linear-gradient(180deg, var(--hb-red), var(--hb-red-deep));
border: 1.5px solid var(--hb-gold);
border-radius: 10px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
color: var(--hb-gold-soft);
pointer-events: none;
font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", serif;
}
/* 左图右文:贴在左图右下角,更小 */
[data-hb-capture-qr].hb-cq--split {
position: absolute;
right: 8px;
bottom: 8px;
padding: 5px 6px;
border-radius: 8px;
}
[data-hb-capture-qr].hb-cq--split .hb-cq-title {
font-size: 9px;
letter-spacing: 0.22em;
padding-left: 0.22em;
margin-bottom: 6px;
}
[data-hb-capture-qr].hb-cq--split .hb-cq-frame { padding: 5px; border-radius: 6px; }
[data-hb-capture-qr].hb-cq--split .hb-cq-c { font-size: 8px; }
[data-hb-capture-qr].hb-cq--split .hb-cq-tip {
font-size: 8px;
letter-spacing: 0.08em;
margin-top: 5px;
}
/* 经典竖版:置于最底部,随容器增高 */
[data-hb-capture-qr].hb-cq--classic {
position: relative;
margin: 36px auto 8px;
flex-shrink: 0;
z-index: 5;
}
.hb-inner--capturing {
min-height: min(940px, calc(100dvh + 100px)) !important;
padding-bottom: 20px !important;
overflow: visible !important;
}
[data-hb-capture-qr] .hb-cq-title {
font-size: 11px;
letter-spacing: 0.35em;
padding-left: 0.35em;
font-weight: 600;
margin-bottom: 8px;
line-height: 1.2;
flex-shrink: 0;
}
[data-hb-capture-qr] .hb-cq-frame {
position: relative;
padding: 8px;
background: linear-gradient(145deg, #fff8e7, var(--hb-cream));
border: 2px solid var(--hb-gold);
border-radius: 8px;
}
[data-hb-capture-qr] .hb-cq-c {
position: absolute;
font-size: 10px;
font-weight: 700;
color: var(--hb-red-bright);
line-height: 1;
z-index: 1;
pointer-events: none;
}
[data-hb-capture-qr] .hb-cq-tl { top: 2px; left: 3px; }
[data-hb-capture-qr] .hb-cq-tr { top: 2px; right: 3px; }
[data-hb-capture-qr] .hb-cq-bl { bottom: 2px; left: 3px; }
[data-hb-capture-qr] .hb-cq-br { bottom: 2px; right: 3px; }
[data-hb-capture-qr] canvas,
[data-hb-capture-qr] img {
display: block;
border-radius: 3px;
}
[data-hb-capture-qr] .hb-cq-tip {
margin-top: 7px;
font-size: 10px;
letter-spacing: 0.12em;
line-height: 1.35;
color: rgba(var(--hb-gold-soft-rgb), 0.92);
text-align: center;
white-space: nowrap;
flex-shrink: 0;
}
</style>