1. 海报

This commit is contained in:
李琦
2026-08-03 10:18:23 +08:00
parent 3b9520eb0f
commit 807adbc6a4
10 changed files with 1155 additions and 21 deletions

View File

@@ -438,6 +438,16 @@
{{ POSTER_TEMPLATES.find(t => t.value === posterCfg.template)?.desc }}
</div>
</a-form-item>
<a-form-item label="手机端布局">
<a-radio-group v-model:value="posterCfg.mobileLayout">
<a-radio-button v-for="t in MOBILE_LAYOUTS" :key="t.value" :value="t.value">
{{ t.label }}
</a-radio-button>
</a-radio-group>
<div class="text-[11px] text-[#8A8680] mt-1">
{{ MOBILE_LAYOUTS.find(t => t.value === posterCfg.mobileLayout)?.desc }}仅窄屏生效PC 不受影响
</div>
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a-form-item label="启用海报页">
<a-switch v-model:checked="posterCfg.enabled" />
@@ -467,6 +477,15 @@
<a-form-item :label="posterCfg.template === 'split' ? '左侧照片' : '顶部照片'">
<image-field v-model="posterCfg.heroImg" label="海报图片" @pick="setPosterHero" :uploading="uploading" />
</a-form-item>
<a-form-item label="二维码中心图">
<image-field
v-model="posterCfg.qrCenterImg"
label="中心图(可选)"
@pick="setPosterQrCenter"
:uploading="uploading"
/>
<div class="text-xs text-stone-500 mt-1">不配置时二维码中心为圆圈大</div>
</a-form-item>
<!-- classic 字段 -->
<template v-if="posterCfg.template === 'classic'">
@@ -890,7 +909,7 @@ import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, sideKey,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, MOBILE_LAYOUTS, sideKey,
} from '@/composables/posterDefaults'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
@@ -969,6 +988,11 @@ function setPosterHero(url) {
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].heroImg = url
}
function setPosterQrCenter(url) {
const side = sideKey(posterSideTab.value)
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].qrCenterImg = url
}
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({

View File

@@ -60,20 +60,37 @@
<div class="hb-enter-veil"></div>
</div>
<template v-if="cfg.enabled && (phase === 'content' || phase === 'enter')">
<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"
/>
</template>
<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右下角固定不占版心空间保存时会把二维码打进海报图 -->
<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 :url="pageUrl" :size="112" :center-img="cfg.qrCenterImg" compact />
</aside>
<div v-if="!cfg.enabled && phase === 'content'" class="hb-disabled">
<p>海报暂未开放</p>
@@ -85,10 +102,13 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import { getPosterConfig } from '@/api/wedding'
import { normalizePosterConfig, resolvePosterSide } from '@/composables/posterDefaults'
import { renderFestiveQr } from '@/composables/renderFestiveQr'
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()
@@ -97,6 +117,9 @@ const cfg = reactive(normalizePosterConfig(null, posterSide.value))
const phase = ref('loading')
const enterLeaving = ref(false)
const contentVisible = ref(false)
const savingImg = ref(false)
const isPc = ref(false)
const pageUrl = computed(() => (typeof window !== 'undefined' ? window.location.href : ''))
let timers = []
function later(fn, ms) {
@@ -105,12 +128,94 @@ function later(fn, ms) {
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)
}
async function mountCaptureQr(host) {
const isSplit = host.classList.contains('sp-card')
const target = isSplit
? (host.querySelector('.sp-left') || host)
: (host.querySelector('.hb-inner') || host)
const qrSize = isSplit ? 64 : 88
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>
`
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, pageUrl.value, {
size: qrSize,
centerImg: cfg.qrCenterImg,
})
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(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 cleanup = null
try {
cleanup = await mountCaptureQr(el)
const canvas = await html2canvas(el, {
useCORS: true,
allowTaint: true,
backgroundColor: '#7a0000',
scale: Math.min(2, window.devicePixelRatio || 2),
ignoreElements: (node) => !!node?.hasAttribute?.('data-html2canvas-ignore'),
})
const a = document.createElement('a')
a.download = `婚礼海报-${cfg.side === 2 ? '女方' : '男方'}-${Date.now()}.png`
a.href = canvas.toDataURL('image/png')
a.click()
} catch (e) {
console.error(e)
window.alert('保存失败,请稍后重试(跨域图片可能导致无法导出)')
} finally {
cleanup?.()
savingImg.value = false
}
}
async function runSequence() {
const loadMs = cfg.loadingEffect === 'fade' ? 700 : 1100
await new Promise((r) => later(r, loadMs))
@@ -135,6 +240,8 @@ async function runSequence() {
}
onMounted(async () => {
refreshPc()
window.addEventListener('resize', refreshPc, { passive: true })
const side = posterSide.value
try {
const res = await getPosterConfig(side)
@@ -148,6 +255,7 @@ onMounted(async () => {
onUnmounted(() => {
timers.forEach(clearTimeout)
timers = []
window.removeEventListener('resize', refreshPc)
})
</script>
@@ -175,6 +283,54 @@ onUnmounted(() => {
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-save-btn {
appearance: none;
border: 1.5px solid #d4af37;
background: linear-gradient(180deg, rgba(212, 175, 55, 0.45), rgba(155, 0, 0, 0.72));
color: #f0d78c;
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 {
@@ -262,3 +418,85 @@ onUnmounted(() => {
@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(240, 215, 140, 0.28), transparent 55%),
linear-gradient(180deg, #9b0000, #6e0000);
border: 1.5px solid #d4af37;
border-radius: 10px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
color: #f0d78c;
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--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, #f7e7c3);
border: 2px solid #d4af37;
border-radius: 8px;
}
[data-hb-capture-qr] .hb-cq-c {
position: absolute;
font-size: 10px;
font-weight: 700;
color: #c41e3a;
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 {
display: block;
border-radius: 3px;
}
</style>

View File

@@ -1,6 +1,9 @@
<template>
<div class="hb-stage" :class="{ 'is-visible': ready }">
<div class="hb-frame">
<div
class="hb-stage"
:class="[{ 'is-visible': ready }, `hb-m-${cfg.mobileLayout || 'auto'}`]"
>
<div class="hb-frame" data-poster-capture>
<div class="hb-frame-ornament hb-frame-ornament-tl" aria-hidden="true">
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg>
</div>
@@ -116,7 +119,11 @@
/>
</div>
<div v-if="cfg.showInviteButton && stage >= 6" class="hb-cta is-show">
<div
v-if="cfg.showInviteButton && stage >= 6"
class="hb-cta is-show"
data-html2canvas-ignore
>
<button type="button" class="hb-cta-btn" @click="$emit('invite')">
{{ cfg.inviteButtonText }}
</button>
@@ -193,6 +200,12 @@ watch(
opacity: 1;
transform: translateY(0) scale(1);
}
@media (max-width: 719px) {
.hb-m-portrait { width: min(420px, 100%); }
.hb-m-landscape { width: min(100%, 640px); }
.hb-m-landscape .hb-inner { min-height: min(520px, 78dvh); }
.hb-m-landscape .hb-title { font-size: clamp(24px, 7vw, 34px); }
}
.hb-frame {
position: relative;
border: 1.5px solid var(--hb-gold);

View File

@@ -1,6 +1,12 @@
<template>
<div class="sp" :class="{ 'is-visible': ready }">
<div class="sp-card">
<div
class="sp"
:class="[
{ 'is-visible': ready },
`sp-m-${cfg.mobileLayout || 'auto'}`,
]"
>
<div class="sp-card" data-poster-capture>
<section class="sp-left">
<div class="sp-photo" :class="{ 'is-show': stage >= 1 }">
<img v-if="cfg.heroImg" :src="cfg.heroImg" alt="" />
@@ -91,7 +97,11 @@
<span class="sp-heart"></span>
</div>
<div v-if="cfg.showInviteButton && stage >= 6" class="sp-cta">
<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>
@@ -174,6 +184,7 @@ watch(
.sp.is-visible { opacity: 1; transform: none; }
.sp-card {
position: relative;
display: flex;
flex-direction: column;
border: 1.5px solid var(--hb-gold);
@@ -184,6 +195,30 @@ watch(
background: #8b0000;
}
/* 手机端:自动 = 上下;竖向强制上下;横屏强制左右 */
@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: 44%; min-height: 0; }
.sp-m-landscape .sp-right { width: 56%; padding: 12px 10px; }
.sp-m-landscape .sp-slogan { font-size: clamp(22px, 6vw, 32px); }
.sp-m-landscape .sp-xi-top { font-size: 32px; }
.sp-m-landscape .sp-name { font-size: 14px; }
.sp-m-landscape .sp-line { font-size: 11px; }
.sp-m-landscape .sp-greeting { font-size: 12px; }
.sp-m-landscape .sp-photo { min-height: 100%; }
}
@media (min-width: 720px) {
.sp-card { flex-direction: row; min-height: min(560px, calc(100dvh - 40px)); }
.sp-left { width: 46%; }