Files
hunli/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue
2026-08-03 17:05:00 +08:00

546 lines
18 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="sp"
:class="[
{ 'is-visible': ready },
`sp-m-${cfg.mobileLayout || 'auto'}`,
]"
>
<div class="sp-shell" data-poster-capture>
<div class="sp-shell-outer">
<div class="sp-shell-gap">
<div class="sp-card">
<div class="sp-frame-ornament sp-frame-ornament-tl" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none">
<path d="M4 44V18c0-8 6-14 14-14h26" stroke="currentColor" stroke-width="2"/>
<path d="M10 44V24c0-6 4-10 10-10h24" stroke="currentColor" stroke-width="1.2" opacity="0.55"/>
<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"/>
</svg>
</div>
<div class="sp-frame-ornament sp-frame-ornament-tr" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none">
<path d="M4 44V18c0-8 6-14 14-14h26" stroke="currentColor" stroke-width="2"/>
<path d="M10 44V24c0-6 4-10 10-10h24" stroke="currentColor" stroke-width="1.2" opacity="0.55"/>
<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"/>
</svg>
</div>
<div class="sp-frame-ornament sp-frame-ornament-bl" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none">
<path d="M4 44V18c0-8 6-14 14-14h26" stroke="currentColor" stroke-width="2"/>
<path d="M10 44V24c0-6 4-10 10-10h24" stroke="currentColor" stroke-width="1.2" opacity="0.55"/>
<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"/>
</svg>
</div>
<div class="sp-frame-ornament sp-frame-ornament-br" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none">
<path d="M4 44V18c0-8 6-14 14-14h26" stroke="currentColor" stroke-width="2"/>
<path d="M10 44V24c0-6 4-10 10-10h24" stroke="currentColor" stroke-width="1.2" opacity="0.55"/>
<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"/>
</svg>
</div>
<div class="sp-card-rim" aria-hidden="true"></div>
<section class="sp-left">
<div class="sp-photo" :class="{ 'is-show': stage >= 1 }">
<img
v-if="cfg.heroImg"
:src="cfg.heroImg"
alt=""
crossorigin="anonymous"
:style="{ objectPosition: heroObjectPosition }"
/>
<div v-else class="sp-photo-ph">婚礼照片</div>
<div class="sp-photo-veil"></div>
</div>
<div class="sp-slogan-block" :class="{ 'is-show': stage >= 2 }">
<BlurText
v-if="stage >= 2"
:text="cfg.verticalSlogan || '吾家有喜'"
:active="stage >= 2"
animate-by="letters"
:delay="90"
class-name="sp-slogan calligraphy-strong"
@animation-complete="bumpStage(3)"
/>
<div v-if="stage >= 3" class="sp-subs">
<BlurText :text="cfg.subSlogan1 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub calligraphy" />
<BlurText :text="cfg.subSlogan2 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub calligraphy" />
</div>
</div>
</section>
<section class="sp-right">
<div class="sp-right-decor" aria-hidden="true">
<span class="sp-wm"></span>
</div>
<div class="sp-xi-top" :class="{ 'is-show': stage >= 3 }"></div>
<BlurText
v-if="stage >= 3"
:text="cfg.greeting || ''"
:active="stage >= 3"
animate-by="letters"
:delay="40"
class-name="sp-greeting calligraphy"
@animation-complete="bumpStage(4)"
/>
<div class="sp-body">
<template v-for="(line, i) in bodyLines" :key="`sp-${i}`">
<TextType
v-if="lineStage > i && i % 2 === 0"
:text="line"
:active="lineStage > i"
:loop="false"
:show-cursor="lineStage === i + 1"
cursor-character="|"
cursor-class-name="sp-cursor"
:typing-speed="42"
class-name="sp-line calligraphy"
@animation-complete="onLineDone(i)"
/>
<BlurText
v-else-if="lineStage > i"
:text="line"
:active="lineStage > i"
animate-by="words"
:delay="70"
class-name="sp-line calligraphy"
@animation-complete="onLineDone(i)"
/>
</template>
</div>
<div class="sp-couple" :class="{ 'is-show': stage >= 5 }">
<div class="sp-person sp-person-l">
<span class="sp-role calligraphy">新郎</span>
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" />
</div>
<canvas ref="xiSealRef" class="sp-xi-mid" width="56" height="56" aria-hidden="true"></canvas>
<div class="sp-person sp-person-r">
<span class="sp-role calligraphy">新娘</span>
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" @animation-complete="bumpStage(6)" />
</div>
</div>
<div class="sp-meta" :class="{ 'is-show': stage >= 6 }">
<div class="sp-meta-title calligraphy">新婚喜宴时间</div>
<div class="sp-meta-val calligraphy">{{ cfg.dateText }}</div>
<div class="sp-meta-sub calligraphy">{{ cfg.lunarText }}</div>
<div class="sp-meta-title mt calligraphy">地点</div>
<div class="sp-meta-val calligraphy">{{ cfg.venueText }}</div>
</div>
<div class="sp-love" :class="{ 'is-show': stage >= 6 }">
<span class="sp-love-text calligraphy">{{ cfg.loveFooter || 'Love you' }}</span>
<span class="sp-heart"></span>
</div>
<div
v-if="cfg.showInviteButton && stage >= 6"
class="sp-cta"
data-html2canvas-ignore
>
<button type="button" class="sp-cta-btn" @click="$emit('invite')">
{{ cfg.inviteButtonText }}
</button>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
import { heroFocusCss } from '@/composables/posterDefaults'
import { drawXiSeal } from '@/composables/drawXiSeal'
const props = defineProps({
cfg: { type: Object, required: true },
ready: { type: Boolean, default: false },
})
defineEmits(['invite'])
const stage = ref(0)
const lineStage = ref(0)
const xiSealRef = ref(null)
let timers = []
const isNarrow = ref(typeof window !== 'undefined' && window.matchMedia('(max-width: 719px)').matches)
let narrowMq = null
function onNarrowChange(e) {
isNarrow.value = !!e.matches
}
const heroObjectPosition = computed(() => heroFocusCss(props.cfg, isNarrow.value ? 'mobile' : undefined))
const bodyLines = computed(() => {
const lines = Array.isArray(props.cfg.lines) ? props.cfg.lines.filter(Boolean) : []
if (lines.length) return lines.slice(0, 4)
return [
'良辰已定,吉日待访,吾有薄酒',
'以谢良友,敬备喜宴,随候尊驾',
]
})
const groom = computed(() => props.cfg.groomName || props.cfg.sonName || '')
const bride = computed(() => props.cfg.brideName || props.cfg.daughterInLawName || '')
async function paintXiSeal() {
try { await document.fonts?.ready } catch { /* ignore */ }
drawXiSeal(xiSealRef.value, {
size: 28,
fontSize: 13,
color: '#d4af37',
borderColor: '#d4af37',
fill: 'rgba(0, 0, 0, 0.18)',
})
}
function later(fn, ms) {
timers.push(setTimeout(fn, ms))
}
function bumpStage(n) {
if (stage.value < n) stage.value = n
if (n === 4) {
if (!bodyLines.value.length) {
later(() => bumpStage(5), 200)
return
}
lineStage.value = 1
}
}
function onLineDone(i) {
if (i + 1 >= bodyLines.value.length) {
bumpStage(5)
return
}
if (lineStage.value === i + 1) lineStage.value = i + 2
}
watch(
() => props.ready,
(v) => {
if (!v) return
later(() => { stage.value = 1 }, 180)
later(() => { stage.value = 2 }, 550)
},
{ immediate: true },
)
onMounted(() => {
nextTick(paintXiSeal)
window.addEventListener('resize', paintXiSeal, { passive: true })
narrowMq = window.matchMedia('(max-width: 719px)')
isNarrow.value = !!narrowMq.matches
narrowMq.addEventListener?.('change', onNarrowChange)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', paintXiSeal)
narrowMq?.removeEventListener?.('change', onNarrowChange)
narrowMq = null
timers.forEach(clearTimeout)
timers = []
})
</script>
<style scoped>
.sp {
position: relative;
z-index: 1;
width: min(720px, 100%);
opacity: 0;
transform: translateY(16px) scale(0.98);
transition: opacity 0.55s ease, transform 0.65s ease;
}
.sp.is-visible { opacity: 1; transform: none; }
.sp-shell {
position: relative;
width: 100%;
}
.sp-shell-outer {
padding: 1px;
border-radius: 14px;
background: linear-gradient(145deg, #f0d78c, #d4af37 45%, #a8892e);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.4);
}
.sp-shell-gap {
padding: 4px;
border-radius: 13px;
background: #7a0000;
border: 1px solid rgba(240, 215, 140, 0.28);
}
.sp-card {
position: relative;
display: flex;
flex-direction: column;
border: 2px solid #d4af37;
border-radius: 10px;
overflow: hidden;
min-height: min(720px, calc(100dvh - 48px));
background: #8b0000;
}
.sp-card-rim {
position: absolute;
inset: 5px;
border: 1px solid rgba(240, 215, 140, 0.45);
border-radius: 7px;
pointer-events: none;
z-index: 6;
}
.sp-frame-ornament {
position: absolute;
width: 28px;
height: 28px;
color: rgba(240, 215, 140, 0.95);
z-index: 7;
pointer-events: none;
}
.sp-frame-ornament svg { width: 100%; height: 100%; display: block; }
.sp-frame-ornament-tl { top: 7px; left: 7px; }
.sp-frame-ornament-tr { top: 7px; right: 7px; transform: scaleX(-1); }
.sp-frame-ornament-bl { bottom: 7px; left: 7px; transform: scaleY(-1); }
.sp-frame-ornament-br { bottom: 7px; right: 7px; transform: scale(-1); }
/* 手机端:自动 = 上下;竖向强制上下;横屏强制左右 */
@media (max-width: 719px) {
.sp-m-auto .sp-card,
.sp-m-portrait .sp-card {
flex-direction: column;
}
.sp-m-auto .sp-left,
.sp-m-portrait .sp-left {
width: 100%;
}
.sp-m-landscape .sp-card {
flex-direction: row;
min-height: min(420px, 70dvh);
}
.sp-m-landscape .sp-left { width: 54%; min-height: 0; }
.sp-m-landscape .sp-right { width: 46%; padding: 10px 8px; }
.sp-m-landscape .sp-slogan { font-size: clamp(22px, 6vw, 32px); }
.sp-m-landscape .sp-photo { min-height: 100%; }
.sp-frame-ornament { width: 22px; height: 22px; }
.sp-card-rim { inset: 4px; }
.sp-shell-gap { padding: 3px; }
}
@media (min-width: 720px) {
.sp-card { flex-direction: row; min-height: min(560px, calc(100dvh - 56px)); }
.sp-left { width: 54%; }
.sp-right { width: 46%; }
}
.sp-left {
position: relative;
min-height: 260px;
background: #c23a2b;
display: flex;
flex-direction: column;
}
.sp-photo {
position: relative;
flex: 1;
min-height: 220px;
opacity: 0;
transition: opacity 0.6s ease;
}
.sp-photo.is-show { opacity: 1; }
.sp-photo img { width: 100%; height: 100%; object-fit: cover; display: block; position: absolute; inset: 0; }
.sp-photo-ph {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: rgba(255, 240, 220, 0.55); letter-spacing: 0.3em;
}
.sp-photo-veil {
position: absolute; inset: 0;
background: linear-gradient(180deg, transparent 40%, rgba(140, 20, 20, 0.55) 100%);
pointer-events: none;
}
.sp-slogan-block {
position: absolute;
left: 10%;
top: 12%;
bottom: 12%;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
opacity: 0;
transition: opacity 0.45s ease;
z-index: 2;
writing-mode: vertical-rl;
text-orientation: mixed;
}
.sp-slogan-block.is-show { opacity: 1; }
/* 须写在基础规则之后,否则 top/left/bottom 会被后面的基础样式盖掉 */
@media (max-width: 719px) {
.sp-slogan-block {
left: auto;
right: 12%;
top: 46%;
bottom: auto;
}
}
.sp-slogan {
color: #f3e6c8 !important;
font-size: clamp(36px, 8vw, 52px);
letter-spacing: 0.2em;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
justify-content: flex-start;
}
.sp-subs { display: flex; flex-direction: column; gap: 18px; margin-top: 8px; }
.sp-sub {
color: rgba(243, 230, 200, 0.88) !important;
font-size: 13px;
letter-spacing: 0.35em;
}
.sp-right {
position: relative;
padding: 22px 20px 18px;
background:
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.06), transparent 30%),
linear-gradient(180deg, #9b0000, #7a0000);
color: var(--hb-gold-soft);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
overflow: hidden;
}
.sp-right-decor { position: absolute; inset: 0; pointer-events: none; }
.sp-wm {
position: absolute; left: 50%; top: 48%; transform: translate(-50%, -50%);
font-size: 180px; font-weight: 700; color: rgba(196, 30, 58, 0.12); line-height: 1;
}
.sp-xi-top {
font-size: 48px; font-weight: 700; color: var(--hb-gold-soft);
opacity: 0; transform: scale(0.8); transition: opacity 0.4s ease, transform 0.4s ease;
margin-bottom: 8px; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); position: relative; z-index: 1;
}
.sp-xi-top.is-show { opacity: 1; transform: scale(1); }
.sp-greeting {
color: #f7efe0 !important; font-size: 14px; letter-spacing: 0.2em;
margin-bottom: 12px; justify-content: center; position: relative; z-index: 1;
}
.sp-body {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: 8px;
min-height: 72px; margin-bottom: 14px; width: 100%;
}
.sp-line {
color: var(--hb-gold-soft) !important; font-size: 13px; letter-spacing: 0.12em;
line-height: 1.55; justify-content: center; text-align: center;
}
.sp-cursor { color: var(--hb-gold); }
.sp-couple {
position: relative;
z-index: 1;
display: flex;
align-items: center;
width: 100%;
margin: 6px 0 14px;
opacity: 0;
transition: opacity 0.4s ease;
}
.sp-couple.is-show { opacity: 1; }
.sp-person {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.sp-person-l { padding-right: 22px; }
.sp-person-r { padding-left: 22px; }
.sp-role { font-size: 11px; color: rgba(247, 239, 224, 0.7); letter-spacing: 0.3em; }
.sp-name { color: var(--hb-gold-soft) !important; font-size: 18px; letter-spacing: 0.2em; justify-content: center; }
/* canvas 位图印章:不用 CSS transform避免 html2canvas 错位 */
.sp-xi-mid {
position: absolute;
left: 50%;
top: 50%;
width: 28px;
height: 28px;
margin-left: -14px;
margin-top: -14px;
display: block;
pointer-events: none;
z-index: 2;
}
.sp-meta {
position: relative; z-index: 1; width: 100%;
opacity: 0; transition: opacity 0.45s ease; margin-bottom: 10px;
}
.sp-meta.is-show { opacity: 1; }
.sp-meta-title {
font-size: 12px; letter-spacing: 0.35em; color: rgba(247, 239, 224, 0.75); margin-bottom: 4px;
}
.sp-meta-title.mt { margin-top: 12px; }
.sp-meta-val { font-size: 14px; letter-spacing: 0.12em; color: var(--hb-gold-soft); }
.sp-meta-sub { font-size: 12px; margin-top: 2px; color: rgba(240, 215, 140, 0.75); letter-spacing: 0.15em; }
.sp-love {
position: relative; z-index: 1;
margin-top: auto; padding-top: 10px;
opacity: 0; transition: opacity 0.4s ease;
display: flex; align-items: center; gap: 6px;
}
.sp-love.is-show { opacity: 1; }
.sp-love-text { font-size: 22px; color: #f3e6c8; }
.sp-heart { color: #e85d5d; font-size: 14px; }
.sp-cta { position: relative; z-index: 1; margin-top: 12px; }
.sp-cta-btn {
appearance: none; border: 1px solid var(--hb-gold);
background: linear-gradient(180deg, rgba(212, 175, 55, 0.22), rgba(212, 175, 55, 0.08));
color: var(--hb-gold-soft); padding: 7px 18px; border-radius: 999px;
letter-spacing: 0.28em; font-size: 12px; cursor: pointer;
}
/* 移动端右侧文案缩小(须在基础字号之后,避免被覆盖) */
@media (max-width: 719px) {
.sp-right { padding: 14px 12px 12px; }
.sp-wm { font-size: 120px; }
.sp-xi-top { font-size: 34px; margin-bottom: 4px; }
.sp-greeting { font-size: 11px; letter-spacing: 0.14em; margin-bottom: 8px; }
.sp-body { gap: 5px; min-height: 56px; margin-bottom: 10px; }
.sp-line { font-size: 11px; letter-spacing: 0.08em; line-height: 1.45; }
.sp-couple { margin: 4px 0 10px; }
.sp-person-l { padding-right: 16px; }
.sp-person-r { padding-left: 16px; }
.sp-role { font-size: 9px; letter-spacing: 0.2em; }
.sp-name { font-size: 14px; letter-spacing: 0.14em; }
.sp-xi-mid { width: 24px; height: 24px; margin-left: -12px; margin-top: -12px; }
.sp-meta-title { font-size: 10px; letter-spacing: 0.22em; margin-bottom: 2px; }
.sp-meta-title.mt { margin-top: 8px; }
.sp-meta-val { font-size: 12px; letter-spacing: 0.08em; }
.sp-meta-sub { font-size: 10px; }
.sp-love-text { font-size: 16px; }
.sp-heart { font-size: 12px; }
.sp-cta { margin-top: 8px; }
.sp-cta-btn { padding: 6px 14px; font-size: 11px; letter-spacing: 0.2em; }
}
</style>