1. 海报
This commit is contained in:
@@ -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(() => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
Reference in New Issue
Block a user