1078 lines
38 KiB
Vue
1078 lines
38 KiB
Vue
<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>
|