1. 海报

This commit is contained in:
李琦
2026-08-03 09:54:43 +08:00
parent 631c1afcc0
commit 3b9520eb0f
9 changed files with 969 additions and 748 deletions

Binary file not shown.

View File

@@ -23,13 +23,14 @@ func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Cont
api.POST("/poster/config", handleSavePosterConfig) api.POST("/poster/config", handleSavePosterConfig)
} }
// resolveSide 返回 "1"(男方)或 "2"(女方);无法识别返回空
func resolveSide(raw string) string { func resolveSide(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw)) v := strings.ToLower(strings.TrimSpace(raw))
switch v { switch v {
case "female", "nv", "f", "bride": case "1", "male", "n", "m", "groom":
return "female" return "1"
case "male", "n", "m", "groom": case "2", "female", "nv", "f", "bride":
return "male" return "2"
default: default:
return "" return ""
} }
@@ -39,13 +40,12 @@ func isLegacyFlat(m map[string]interface{}) bool {
if m == nil { if m == nil {
return false return false
} }
if _, ok := m["male"]; ok { for _, k := range []string{"1", "2", "male", "female"} {
return false if _, ok := m[k]; ok {
return false
}
} }
if _, ok := m["female"]; ok { for _, k := range []string{"title", "lines", "heroImg", "sonName", "template"} {
return false
}
for _, k := range []string{"title", "lines", "heroImg", "sonName"} {
if _, ok := m[k]; ok { if _, ok := m[k]; ok {
return true return true
} }
@@ -64,24 +64,33 @@ func asObject(v interface{}) map[string]interface{} {
return emptyObj() return emptyObj()
} }
// normalizeBundle 统一为 { male, female };旧扁平配置迁入 male func takeSide(raw map[string]interface{}, keys ...string) map[string]interface{} {
for _, k := range keys {
if m := asObject(raw[k]); len(m) > 0 {
return m
}
}
return emptyObj()
}
// normalizeBundle 统一为 { "1", "2" };兼容旧 male/female 与扁平配置
func normalizeBundle(raw map[string]interface{}) map[string]interface{} { func normalizeBundle(raw map[string]interface{}) map[string]interface{} {
if raw == nil { if raw == nil {
raw = emptyObj() raw = emptyObj()
} }
out := map[string]interface{}{ out := map[string]interface{}{
"male": emptyObj(), "1": emptyObj(),
"female": emptyObj(), "2": emptyObj(),
} }
if isLegacyFlat(raw) { if isLegacyFlat(raw) {
out["male"] = raw out["1"] = raw
return out return out
} }
if m := asObject(raw["male"]); len(m) > 0 { if m := takeSide(raw, "1", "male"); len(m) > 0 {
out["male"] = m out["1"] = m
} }
if f := asObject(raw["female"]); len(f) > 0 { if f := takeSide(raw, "2", "female"); len(f) > 0 {
out["female"] = f out["2"] = f
} }
return out return out
} }

View File

@@ -84,9 +84,9 @@ export function saveUploadConfig(payload) {
return service.post('/upload/config', payload) return service.post('/upload/config', payload)
} }
/** side 可选male|female|n|nv不传则返回 { male, female } 整包 */ /** side 可选:1|2亦兼容 male/female/n/nv;不传则返回 { "1", "2" } 整包 */
export function getPosterConfig(side) { export function getPosterConfig(side) {
const params = side ? { side } : undefined const params = side != null && side !== '' ? { side } : undefined
return service.get('/poster/config', { params }) return service.get('/poster/config', { params })
} }

View File

@@ -1,19 +1,34 @@
/** 海报 sidemale=/hb-n 男方female=/hb-nv 女方 */ /** 海报 side1=男方(/hb-n)2=女方(/hb-nv) */
export const POSTER_SIDES = [ export const POSTER_SIDES = [
{ key: 'male', route: '/hb-n', label: '男方', short: 'n' }, { key: 1, keyStr: '1', route: '/hb-n', label: '男方', short: 'n' },
{ key: 'female', route: '/hb-nv', label: '女方', short: 'nv' }, { key: 2, keyStr: '2', route: '/hb-nv', label: '女方', short: 'nv' },
] ]
export const POSTER_TEMPLATES = [
{ value: 'classic', label: '经典竖版', desc: '顶图 + 正文逐行出现' },
{ value: 'split', label: '左图右文', desc: '竖排书法 + 右侧宴席信息' },
]
/** 解析为 1 | 2无法识别返回 0 */
export function resolvePosterSide(input) { export function resolvePosterSide(input) {
if (input === 1 || input === '1') return 1
if (input === 2 || input === '2') return 2
const v = String(input || '').trim().toLowerCase() const v = String(input || '').trim().toLowerCase()
if (v === 'female' || v === 'nv' || v === 'f' || v === 'bride') return 'female' if (v === 'female' || v === 'nv' || v === 'f' || v === 'bride') return 2
if (v === 'male' || v === 'n' || v === 'm' || v === 'groom') return 'male' if (v === 'male' || v === 'n' || v === 'm' || v === 'groom') return 1
return '' return 0
} }
function baseFields() { export function sideKey(side) {
const s = resolvePosterSide(side) || 1
return String(s)
}
function baseFields(side) {
return { return {
side: resolvePosterSide(side) || 1,
template: 'classic',
enabled: true, enabled: true,
heroImg: '', heroImg: '',
title: '婚礼邀请函', title: '婚礼邀请函',
@@ -23,17 +38,32 @@ function baseFields() {
showInviteButton: true, showInviteButton: true,
inviteButtonText: '打开请柬', inviteButtonText: '打开请柬',
invitePath: '/', invitePath: '/',
// split 模板字段
verticalSlogan: '吾家有喜',
subSlogan1: '佳偶天成',
subSlogan2: '百年好合',
greeting: '尊敬的各位亲朋好友',
groomName: '',
brideName: '',
dateText: '2026年10月3日 星期日',
lunarText: '农历九月初八',
venueText: '幸福大酒店二楼宴会厅',
loveFooter: 'Love you',
} }
} }
/** 男方默认文案 */ /** 男方(1) 默认 */
export function defaultMalePosterConfig() { export function defaultSide1PosterConfig() {
return { return {
...baseFields(), ...baseFields(1),
side: 1,
sonLabel: '儿子:', sonLabel: '儿子:',
sonName: '张浩强', sonName: '张浩强',
daughterInLawLabel: '儿媳:', daughterInLawLabel: '儿媳:',
daughterInLawName: '刘佳怡', daughterInLawName: '刘佳怡',
groomName: '张浩强',
brideName: '刘佳怡',
verticalSlogan: '吾家有喜',
lines: [ lines: [
'各位亲朋好友', '各位亲朋好友',
'吾家有喜 儿子结婚', '吾家有喜 儿子结婚',
@@ -48,14 +78,18 @@ export function defaultMalePosterConfig() {
} }
} }
/** 女方默认文案 */ /** 女方(2) 默认 */
export function defaultFemalePosterConfig() { export function defaultSide2PosterConfig() {
return { return {
...baseFields(), ...baseFields(2),
side: 2,
sonLabel: '女儿:', sonLabel: '女儿:',
sonName: '刘佳怡', sonName: '刘佳怡',
daughterInLawLabel: '女婿:', daughterInLawLabel: '女婿:',
daughterInLawName: '张浩强', daughterInLawName: '张浩强',
groomName: '张浩强',
brideName: '刘佳怡',
verticalSlogan: '小女出阁',
lines: [ lines: [
'各位亲朋好友', '各位亲朋好友',
'吾家有喜 女儿出嫁', '吾家有喜 女儿出嫁',
@@ -70,15 +104,20 @@ export function defaultFemalePosterConfig() {
} }
} }
/** @deprecated 兼容旧调用,等同男方默认 */ export function defaultMalePosterConfig() {
return defaultSide1PosterConfig()
}
export function defaultFemalePosterConfig() {
return defaultSide2PosterConfig()
}
export function defaultPosterConfig() { export function defaultPosterConfig() {
return defaultMalePosterConfig() return defaultSide1PosterConfig()
} }
export function defaultPosterBundle() { export function defaultPosterBundle() {
return { return {
male: defaultMalePosterConfig(), '1': defaultSide1PosterConfig(),
female: defaultFemalePosterConfig(), '2': defaultSide2PosterConfig(),
} }
} }
@@ -94,24 +133,42 @@ export const ENTER_EFFECTS = [
{ value: 'curtain', label: '金幕拉开' }, { value: 'curtain', label: '金幕拉开' },
] ]
/** 是否为旧版扁平配置(未分男女方) */
function isLegacyFlatPoster(raw) { function isLegacyFlatPoster(raw) {
if (!raw || typeof raw !== 'object') return false if (!raw || typeof raw !== 'object') return false
if (raw.male || raw.female) return false if (raw['1'] || raw['2'] || raw[1] || raw[2] || raw.male || raw.female) return false
return 'title' in raw || 'lines' in raw || 'heroImg' in raw || 'sonName' in raw return 'title' in raw || 'lines' in raw || 'heroImg' in raw || 'sonName' in raw || 'template' in raw
} }
export function normalizePosterConfig(raw, side = 'male') { function pickSideSource(loaded, side) {
const s = resolvePosterSide(side) || 'male' const k = sideKey(side)
const base = s === 'female' ? defaultFemalePosterConfig() : defaultMalePosterConfig() if (loaded[k] && typeof loaded[k] === 'object') return loaded[k]
if (side === 1 && loaded.male && typeof loaded.male === 'object') return loaded.male
if (side === 2 && loaded.female && typeof loaded.female === 'object') return loaded.female
return null
}
export function normalizePosterConfig(raw, side = 1) {
const s = resolvePosterSide(side) || 1
const base = s === 2 ? defaultSide2PosterConfig() : defaultSide1PosterConfig()
const loaded = raw && typeof raw === 'object' ? raw : {} const loaded = raw && typeof raw === 'object' ? raw : {}
const lines = Array.isArray(loaded.lines) const lines = Array.isArray(loaded.lines)
? loaded.lines.map((l) => String(l ?? '')) ? loaded.lines.map((l) => String(l ?? ''))
: base.lines : base.lines
const template = POSTER_TEMPLATES.some((t) => t.value === loaded.template)
? loaded.template
: base.template
const groomName = loaded.groomName || loaded.sonName || base.groomName
const brideName = loaded.brideName || loaded.daughterInLawName || base.brideName
return { return {
...base, ...base,
...loaded, ...loaded,
side: s,
template,
lines: lines.length ? lines : base.lines, lines: lines.length ? lines : base.lines,
groomName,
brideName,
enabled: loaded.enabled !== false, enabled: loaded.enabled !== false,
showInviteButton: loaded.showInviteButton !== false, showInviteButton: loaded.showInviteButton !== false,
loadingEffect: LOADING_EFFECTS.some((e) => e.value === loaded.loadingEffect) loadingEffect: LOADING_EFFECTS.some((e) => e.value === loaded.loadingEffect)
@@ -122,26 +179,34 @@ export function normalizePosterConfig(raw, side = 'male') {
: base.enterEffect, : base.enterEffect,
invitePath: loaded.invitePath || '/', invitePath: loaded.invitePath || '/',
inviteButtonText: loaded.inviteButtonText || base.inviteButtonText, inviteButtonText: loaded.inviteButtonText || base.inviteButtonText,
verticalSlogan: loaded.verticalSlogan || base.verticalSlogan,
subSlogan1: loaded.subSlogan1 || base.subSlogan1,
subSlogan2: loaded.subSlogan2 || base.subSlogan2,
greeting: loaded.greeting || base.greeting,
dateText: loaded.dateText || base.dateText,
lunarText: loaded.lunarText || base.lunarText,
venueText: loaded.venueText || base.venueText,
loveFooter: loaded.loveFooter || base.loveFooter,
} }
} }
/** 归一化整包 { male, female };兼容旧扁平数据迁入 male */ /** 归一化 { 1, 2 };兼容旧 male/female 与扁平数据 */
export function normalizePosterBundle(raw) { export function normalizePosterBundle(raw) {
const loaded = raw && typeof raw === 'object' ? raw : {} const loaded = raw && typeof raw === 'object' ? raw : {}
if (isLegacyFlatPoster(loaded)) { if (isLegacyFlatPoster(loaded)) {
return { return {
male: normalizePosterConfig(loaded, 'male'), '1': normalizePosterConfig(loaded, 1),
female: defaultFemalePosterConfig(), '2': defaultSide2PosterConfig(),
} }
} }
return { return {
male: normalizePosterConfig(loaded.male, 'male'), '1': normalizePosterConfig(pickSideSource(loaded, 1) || {}, 1),
female: normalizePosterConfig(loaded.female, 'female'), '2': normalizePosterConfig(pickSideSource(loaded, 2) || {}, 2),
} }
} }
export function pickPosterSide(bundle, side) { export function pickPosterSide(bundle, side) {
const normalized = normalizePosterBundle(bundle) const normalized = normalizePosterBundle(bundle)
const s = resolvePosterSide(side) || 'male' const s = sideKey(side)
return normalized[s] return normalized[s]
} }

View File

@@ -16,13 +16,13 @@ const routes = [
path: '/hb-n', path: '/hb-n',
name: 'PosterMale', name: 'PosterMale',
component: () => import('@/views/hb/index.vue'), component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 'male' }, meta: { posterSide: 1 },
}, },
{ {
path: '/hb-nv', path: '/hb-nv',
name: 'PosterFemale', name: 'PosterFemale',
component: () => import('@/views/hb/index.vue'), component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 'female' }, meta: { posterSide: 2 },
}, },
{ {
path: '/login', path: '/login',

View File

@@ -19,8 +19,8 @@
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
<a-menu-item key="male" @click="openPosterPreview('male')">男方 /hb-n</a-menu-item> <a-menu-item key="1" @click="openPosterPreview(1)">男方(1) /hb-n</a-menu-item>
<a-menu-item key="female" @click="openPosterPreview('female')">女方 /hb-nv</a-menu-item> <a-menu-item key="2" @click="openPosterPreview(2)">女方(2) /hb-nv</a-menu-item>
</a-menu> </a-menu>
</template> </template>
</a-dropdown> </a-dropdown>
@@ -410,20 +410,34 @@
</a-card> </a-card>
</a-tab-pane> </a-tab-pane>
<!-- 海报独立 poster_configs男方 /hb-n女方 /hb-nv --> <!-- 海报独立 poster_configsside 1 男方 /hb-n2 女方 /hb-nv -->
<a-tab-pane key="poster" tab="海报"> <a-tab-pane key="poster" tab="海报">
<a-card v-if="activeTab === 'poster'" :bordered="false" class="!shadow-sm"> <a-card v-if="activeTab === 'poster'" :bordered="false" class="!shadow-sm">
<a-alert <a-alert
type="info" type="info"
show-icon show-icon
class="mb-4" class="mb-4"
message="海报配置独立存储。男方前台 /hb-n女方前台 /hb-nv" message="海报配置独立存储。side=1 男方 /hb-nside=2 女方 /hb-nv两侧可分别选择模板。"
/> />
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-3"> <a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-3">
<a-tab-pane key="male" tab="男方海报" /> <a-tab-pane key="1" tab="男方(1)" />
<a-tab-pane key="female" tab="女方海报" /> <a-tab-pane key="2" tab="女方(2)" />
</a-tabs> </a-tabs>
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }"> <a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
<a-form-item label="所属方">
<a-tag color="gold">{{ posterSideTab === '2' ? '2 · 女方' : '1 · 男方' }}</a-tag>
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ posterSideTab === '2' ? '/hb-nv' : '/hb-n' }}</span>
</a-form-item>
<a-form-item label="版式模板">
<a-radio-group v-model:value="posterCfg.template" class="poster-tpl-group">
<a-radio-button v-for="t in POSTER_TEMPLATES" :key="t.value" :value="t.value">
{{ t.label }}
</a-radio-button>
</a-radio-group>
<div class="text-[11px] text-[#8A8680] mt-1">
{{ POSTER_TEMPLATES.find(t => t.value === posterCfg.template)?.desc }}
</div>
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a-form-item label="启用海报页"> <a-form-item label="启用海报页">
<a-switch v-model:checked="posterCfg.enabled" /> <a-switch v-model:checked="posterCfg.enabled" />
@@ -450,39 +464,85 @@
<a-input v-model:value="posterCfg.invitePath" placeholder="/" /> <a-input v-model:value="posterCfg.invitePath" placeholder="/" />
</a-form-item> </a-form-item>
</div> </div>
<a-form-item label="顶部照片"> <a-form-item :label="posterCfg.template === 'split' ? '左侧照片' : '顶部照片'">
<image-field v-model="posterCfg.heroImg" label="海报图" @pick="openUpload(u => posterCfg.heroImg = u)" :uploading="uploading" /> <image-field v-model="posterCfg.heroImg" label="海报图" @pick="setPosterHero" :uploading="uploading" />
</a-form-item> </a-form-item>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item :label="posterSideTab === 'female' ? '女儿称谓 / 姓名' : '儿子称谓 / 姓名'"> <!-- classic 字段 -->
<div class="flex gap-2"> <template v-if="posterCfg.template === 'classic'">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterSideTab === 'female' ? '女儿:' : '儿子:'" /> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" /> <a-form-item :label="posterSideTab === '2' ? '女儿称谓 / 姓名' : '儿子称谓 / 姓名'">
</div> <div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterSideTab === '2' ? '女儿:' : '儿子:'" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" />
</div>
</a-form-item>
<a-form-item :label="posterSideTab === '2' ? '女婿称谓 / 姓名' : '儿媳称谓 / 姓名'">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterSideTab === '2' ? '女婿:' : '儿媳:'" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" />
</div>
</a-form-item>
</div>
<a-form-item label="主标题">
<a-input v-model:value="posterCfg.title" placeholder="婚礼邀请函" />
</a-form-item> </a-form-item>
<a-form-item :label="posterSideTab === 'female' ? '女婿称谓 / 姓名' : '儿媳称谓 / 姓名'"> <a-form-item label="正文(每行一条)">
<div class="flex gap-2"> <a-textarea v-model:value="posterLinesText" :rows="10" placeholder="每行一条正文" />
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterSideTab === 'female' ? '女婿:' : '儿媳:'" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" />
</div>
</a-form-item> </a-form-item>
</div> <a-form-item label="底部文案">
<a-form-item label="主标题"> <a-input v-model:value="posterCfg.footer" />
<a-input v-model:value="posterCfg.title" placeholder="婚礼邀请函" /> </a-form-item>
</a-form-item> </template>
<a-form-item label="正文(每行一条)">
<a-textarea v-model:value="posterLinesText" :rows="10" placeholder="每行一条正文" /> <!-- split 字段 -->
</a-form-item> <template v-else>
<a-form-item label="底部文案"> <div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
<a-input v-model:value="posterCfg.footer" /> <a-form-item label="竖排主标">
</a-form-item> <a-input v-model:value="posterCfg.verticalSlogan" placeholder="吾家有喜" />
</a-form-item>
<a-form-item label="副标一">
<a-input v-model:value="posterCfg.subSlogan1" placeholder="佳偶天成" />
</a-form-item>
<a-form-item label="副标二">
<a-input v-model:value="posterCfg.subSlogan2" placeholder="百年好合" />
</a-form-item>
</div>
<a-form-item label="问候语">
<a-input v-model:value="posterCfg.greeting" placeholder="尊敬的各位亲朋好友" />
</a-form-item>
<a-form-item label="正文(每行一条,建议 24 行)">
<a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="新郎">
<a-input v-model:value="posterCfg.groomName" />
</a-form-item>
<a-form-item label="新娘">
<a-input v-model:value="posterCfg.brideName" />
</a-form-item>
<a-form-item label="喜宴时间">
<a-input v-model:value="posterCfg.dateText" />
</a-form-item>
<a-form-item label="农历">
<a-input v-model:value="posterCfg.lunarText" />
</a-form-item>
</div>
<a-form-item label="地点">
<a-input v-model:value="posterCfg.venueText" />
</a-form-item>
<a-form-item label="底部 Love 文案">
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
</a-form-item>
</template>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<a-button type="primary" :loading="posterSaving" @click="onSavePosterConfig" <a-button type="primary" :loading="posterSaving" @click="onSavePosterConfig"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"> class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
<i class="fa-solid fa-floppy-disk mr-1"></i>保存海报配置 <i class="fa-solid fa-floppy-disk mr-1"></i>保存海报配置
</a-button> </a-button>
<a-button @click="openPosterPreview(posterSideTab)"> <a-button @click="openPosterPreview(posterSideTab)">
<i class="fa-solid fa-eye mr-1"></i>打开 {{ posterSideTab === 'female' ? '/hb-nv' : '/hb-n' }} 预览 <i class="fa-solid fa-eye mr-1"></i>打开 {{ posterSideTab === '2' ? '/hb-nv' : '/hb-n' }} 预览
</a-button> </a-button>
</div> </div>
</a-form> </a-form>
@@ -830,7 +890,7 @@ import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage' import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import { import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig, defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, sideKey,
} from '@/composables/posterDefaults' } from '@/composables/posterDefaults'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer]) echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
@@ -888,22 +948,27 @@ let regionChart = null
const cfg = reactive(defaultConfig()) const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig()) const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('male') const posterSideTab = ref('1')
const posterBundle = reactive(defaultPosterBundle()) const posterBundle = reactive(defaultPosterBundle())
const posterCfg = computed(() => posterBundle[posterSideTab.value] || posterBundle.male) const posterCfg = computed(() => posterBundle[sideKey(posterSideTab.value)] || posterBundle['1'])
const posterLinesText = computed({ const posterLinesText = computed({
get: () => { get: () => {
const lines = posterCfg.value?.lines const lines = posterCfg.value?.lines
return Array.isArray(lines) ? lines.join('\n') : '' return Array.isArray(lines) ? lines.join('\n') : ''
}, },
set: (v) => { set: (v) => {
const side = posterSideTab.value const side = sideKey(posterSideTab.value)
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side) if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].lines = String(v || '') posterBundle[side].lines = String(v || '')
.split('\n') .split('\n')
.map((s) => s.trimEnd()) .map((s) => s.trimEnd())
}, },
}) })
function setPosterHero(url) {
const side = sideKey(posterSideTab.value)
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].heroImg = url
}
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0))) 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 isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({ const visitPagination = computed(() => ({
@@ -1324,8 +1389,8 @@ async function loadPosterConfig() {
try { try {
const res = await getPosterConfig() const res = await getPosterConfig()
const bundle = normalizePosterBundle(res.data || {}) const bundle = normalizePosterBundle(res.data || {})
posterBundle.male = bundle.male posterBundle['1'] = bundle['1']
posterBundle.female = bundle.female posterBundle['2'] = bundle['2']
} catch (e) { } catch (e) {
message.error(e?.message || '海报配置加载失败') message.error(e?.message || '海报配置加载失败')
} finally { } finally {
@@ -1339,8 +1404,8 @@ async function onSavePosterConfig() {
const payload = normalizePosterBundle(posterBundle) const payload = normalizePosterBundle(posterBundle)
const res = await savePosterConfig(payload) const res = await savePosterConfig(payload)
const bundle = normalizePosterBundle(res.data || payload) const bundle = normalizePosterBundle(res.data || payload)
posterBundle.male = bundle.male posterBundle['1'] = bundle['1']
posterBundle.female = bundle.female posterBundle['2'] = bundle['2']
message.success('海报配置已保存') message.success('海报配置已保存')
} catch (e) { } catch (e) {
message.error(e?.message || '海报配置保存失败') message.error(e?.message || '海报配置保存失败')
@@ -1547,8 +1612,9 @@ async function onTabActivate(tab) {
} }
function openPreview() { window.open('/', '_blank') } function openPreview() { window.open('/', '_blank') }
function openPosterPreview(side = 'male') { function openPosterPreview(side = 1) {
const meta = POSTER_SIDES.find((s) => s.key === side) || POSTER_SIDES[0] const s = Number(sideKey(side))
const meta = POSTER_SIDES.find((x) => x.key === s) || POSTER_SIDES[0]
window.open(meta.route, '_blank') window.open(meta.route, '_blank')
} }

View File

@@ -1,6 +1,5 @@
<template> <template>
<div class="hb-root"> <div class="hb-root">
<!-- 全屏背景婚礼装饰 -->
<div class="hb-bg-decor" aria-hidden="true"> <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-1"></span>
<span class="hb-bg-xi hb-bg-xi-2"></span> <span class="hb-bg-xi hb-bg-xi-2"></span>
@@ -20,7 +19,6 @@
<i class="hb-bg-spark hb-bg-spark-4"></i> <i class="hb-bg-spark hb-bg-spark-4"></i>
</div> </div>
<!-- 共用 SVG 图标定义 -->
<svg class="hb-sprite" aria-hidden="true"> <svg class="hb-sprite" aria-hidden="true">
<symbol id="hb-icon-knot" viewBox="0 0 64 64"> <symbol id="hb-icon-knot" viewBox="0 0 64 64">
<path d="M32 6c-4 8-14 12-14 22 0 6 4 10 10 12-6 2-10 6-10 12 0 10 10 14 14 22 4-8 14-12 14-22 0-6-4-10-10-12 6-2 10-6 10-12 0-10-10-14-14-22z" stroke="currentColor" stroke-width="2.2" fill="none"/> <path d="M32 6c-4 8-14 12-14 22 0 6 4 10 10 12-6 2-10 6-10 12 0 10 10 14 14 22 4-8 14-12 14-22 0-6-4-10-10-12 6-2 10-6 10-12 0-10-10-14-14-22z" stroke="currentColor" stroke-width="2.2" fill="none"/>
@@ -46,14 +44,12 @@
</symbol> </symbol>
</svg> </svg>
<!-- Loading -->
<div v-if="phase === 'loading'" class="hb-loading" :class="`hb-loading--${cfg.loadingEffect}`"> <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-if="cfg.loadingEffect === 'redSeal'" class="hb-seal"></div>
<div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="hb-shimmer">婚礼邀请函</div> <div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="hb-shimmer">婚礼邀请函</div>
<div v-else class="hb-fade-dot"></div> <div v-else class="hb-fade-dot"></div>
</div> </div>
<!-- Enter curtain -->
<div <div
v-if="phase === 'enter'" v-if="phase === 'enter'"
class="hb-enter" class="hb-enter"
@@ -64,133 +60,20 @@
<div class="hb-enter-veil"></div> <div class="hb-enter-veil"></div>
</div> </div>
<!-- Poster --> <template v-if="cfg.enabled && (phase === 'content' || phase === 'enter')">
<div v-show="phase === 'content' || phase === 'enter'" class="hb-stage" :class="{ 'is-visible': contentVisible }"> <ClassicPoster
<div class="hb-frame"> v-if="cfg.template === 'classic'"
<div class="hb-frame-ornament hb-frame-ornament-tl" aria-hidden="true"> :cfg="cfg"
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg> :ready="contentVisible && phase === 'content'"
</div> @invite="goInvite"
<div class="hb-frame-ornament hb-frame-ornament-tr" aria-hidden="true"> />
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg> <SplitPoster
</div> v-else
<div class="hb-frame-ornament hb-frame-ornament-bl" aria-hidden="true"> :cfg="cfg"
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg> :ready="contentVisible && phase === 'content'"
</div> @invite="goInvite"
<div class="hb-frame-ornament hb-frame-ornament-br" aria-hidden="true"> />
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg> </template>
</div>
<div class="hb-inner">
<!-- 纸面装饰角花水印囍同心结祥云 -->
<div class="hb-paper-decor" aria-hidden="true">
<span class="hb-paper-watermark"></span>
<svg class="hb-paper-knot hb-paper-knot-tl" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-knot hb-paper-knot-tr" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-knot hb-paper-knot-bl" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-knot hb-paper-knot-br" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-cloud hb-paper-cloud-t" viewBox="0 0 80 36"><use href="#hb-icon-cloud" /></svg>
<svg class="hb-paper-cloud hb-paper-cloud-b" viewBox="0 0 80 36"><use href="#hb-icon-cloud" /></svg>
<span class="hb-paper-xi hb-paper-xi-l"></span>
<span class="hb-paper-xi hb-paper-xi-r"></span>
<span class="hb-paper-dot hb-paper-dot-1"></span>
<span class="hb-paper-dot hb-paper-dot-2"></span>
<span class="hb-paper-dot hb-paper-dot-3"></span>
<span class="hb-paper-dot hb-paper-dot-4"></span>
<div class="hb-paper-side hb-paper-side-l"></div>
<div class="hb-paper-side hb-paper-side-r"></div>
</div>
<div class="hb-hero-wrap" :class="{ 'is-show': stage >= 1 }">
<div class="hb-hero">
<img v-if="cfg.heroImg" :src="cfg.heroImg" alt="" class="hb-hero-img" />
<div v-else class="hb-hero-placeholder">婚礼照片</div>
<span class="hb-xi hb-xi-l"></span>
<span class="hb-xi hb-xi-r"></span>
</div>
</div>
<div class="hb-names" :class="{ 'is-show': stage >= 2 }">
<BlurText
v-if="stage >= 2"
:text="nameLeft"
:active="stage >= 2"
animate-by="letters"
:delay="40"
class-name="hb-name"
@animation-complete="onNamesLeftDone"
/>
<span class="hb-xi-circle"></span>
<BlurText
v-if="namesRightReady"
:text="nameRight"
:active="namesRightReady"
animate-by="letters"
:delay="40"
class-name="hb-name"
@animation-complete="bumpStage(3)"
/>
</div>
<div class="hb-title-wrap" :class="{ 'is-show': stage >= 3 }">
<BlurText
v-if="stage >= 3"
:text="cfg.title"
:active="stage >= 3"
animate-by="letters"
direction="bottom"
:delay="70"
:step-duration="0.4"
class-name="hb-title calligraphy-strong"
@animation-complete="bumpStage(4)"
/>
</div>
<div class="hb-body">
<template v-for="(line, i) in cfg.lines" :key="`line-${i}-${line}`">
<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="hb-cursor"
:typing-speed="48"
:initial-delay="80"
class-name="hb-line"
@animation-complete="onLineDone(i)"
/>
<BlurText
v-else-if="lineStage > i"
:text="line"
:active="lineStage > i"
animate-by="words"
:delay="90"
class-name="hb-line"
@animation-complete="onLineDone(i)"
/>
</template>
</div>
<div class="hb-footer" :class="{ 'is-show': stage >= 5 }">
<BlurText
v-if="stage >= 5"
:text="cfg.footer"
:active="stage >= 5"
animate-by="letters"
:delay="35"
class-name="hb-footer-text"
@animation-complete="bumpStage(6)"
/>
</div>
<div v-if="cfg.showInviteButton && stage >= 6" class="hb-cta is-show">
<button type="button" class="hb-cta-btn" @click="goInvite">
{{ cfg.inviteButtonText }}
</button>
</div>
</div>
</div>
</div>
<div v-if="!cfg.enabled && phase === 'content'" class="hb-disabled"> <div v-if="!cfg.enabled && phase === 'content'" class="hb-disabled">
<p>海报暂未开放</p> <p>海报暂未开放</p>
@@ -204,21 +87,16 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getPosterConfig } from '@/api/wedding' import { getPosterConfig } from '@/api/wedding'
import { normalizePosterConfig, resolvePosterSide } from '@/composables/posterDefaults' import { normalizePosterConfig, resolvePosterSide } from '@/composables/posterDefaults'
import BlurText from '@/components/vue-bits/BlurText.vue' import ClassicPoster from '@/views/hb/templates/ClassicPoster.vue'
import TextType from '@/components/vue-bits/TextType.vue' import SplitPoster from '@/views/hb/templates/SplitPoster.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const posterSide = computed(() => resolvePosterSide(route.meta.posterSide) || 'male') const posterSide = computed(() => resolvePosterSide(route.meta.posterSide) || 1)
const cfg = reactive(normalizePosterConfig(null, posterSide.value)) const cfg = reactive(normalizePosterConfig(null, posterSide.value))
const phase = ref('loading') // loading | enter | content const phase = ref('loading')
const enterLeaving = ref(false) const enterLeaving = ref(false)
const contentVisible = ref(false) const contentVisible = ref(false)
const stage = ref(0)
const lineStage = ref(0)
const namesRightReady = ref(false)
const nameLeft = computed(() => `${cfg.sonLabel || ''}${cfg.sonName || ''}`)
const nameRight = computed(() => `${cfg.daughterInLawLabel || ''}${cfg.daughterInLawName || ''}`)
let timers = [] let timers = []
function later(fn, ms) { function later(fn, ms) {
@@ -227,29 +105,6 @@ function later(fn, ms) {
return id return id
} }
function bumpStage(n) {
if (stage.value < n) stage.value = n
if (n === 4) {
if (!cfg.lines?.length) {
later(() => bumpStage(5), 200)
return
}
lineStage.value = 1
}
}
function onNamesLeftDone() {
namesRightReady.value = true
}
function onLineDone(i) {
if (i + 1 >= cfg.lines.length) {
bumpStage(5)
return
}
if (lineStage.value === i + 1) lineStage.value = i + 2
}
function goInvite() { function goInvite() {
const path = cfg.invitePath || '/' const path = cfg.invitePath || '/'
if (path.startsWith('http')) window.location.href = path if (path.startsWith('http')) window.location.href = path
@@ -276,10 +131,7 @@ async function runSequence() {
contentVisible.value = true contentVisible.value = true
}, 80) }, 80)
await new Promise((r) => later(r, enterMs)) await new Promise((r) => later(r, enterMs))
phase.value = 'content' phase.value = 'content'
later(() => { stage.value = 1 }, 200)
later(() => { stage.value = 2 }, 700)
} }
onMounted(async () => { onMounted(async () => {
@@ -323,130 +175,56 @@ onUnmounted(() => {
overflow-x: hidden; overflow-x: hidden;
font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", serif; font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", serif;
} }
.hb-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.hb-sprite { .hb-bg-decor { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
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 { .hb-bg-xi {
position: absolute; position: absolute; color: rgba(240, 215, 140, 0.14); font-weight: 700; line-height: 1;
color: rgba(240, 215, 140, 0.14); user-select: none; animation: hb-pulse-soft 7s ease-in-out infinite;
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); animation-delay: 0s; } .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); animation-delay: 1.2s; color: rgba(196, 30, 58, 0.22); } .hb-bg-xi-2 { top: 14%; right: 6%; font-size: 42px; transform: rotate(10deg); color: rgba(196, 30, 58, 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-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); animation-delay: 0.6s; color: rgba(196, 30, 58, 0.18); } .hb-bg-xi-4 { bottom: 10%; right: 10%; font-size: 64px; transform: rotate(-8deg); color: rgba(196, 30, 58, 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-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-xi-6 { top: 48%; right: 3%; font-size: 40px; opacity: 0.75; animation-delay: 2.4s; }
.hb-bg-knot { position: absolute; color: rgba(212, 175, 55, 0.28); width: 72px; height: 72px; animation: hb-float 10s ease-in-out infinite; }
.hb-bg-knot { .hb-bg-knot-1 { top: 8%; left: 14%; width: 64px; height: 64px; }
position: absolute;
color: rgba(212, 175, 55, 0.28);
width: 72px;
height: 72px;
animation: hb-float 10s ease-in-out infinite;
}
.hb-bg-knot-1 { top: 8%; left: 14%; width: 64px; height: 64px; animation-delay: 0.4s; }
.hb-bg-knot-2 { top: 22%; right: 12%; width: 56px; height: 56px; color: rgba(196, 30, 58, 0.28); animation-delay: 1.8s; } .hb-bg-knot-2 { top: 22%; right: 12%; width: 56px; height: 56px; color: rgba(196, 30, 58, 0.28); animation-delay: 1.8s; }
.hb-bg-knot-3 { bottom: 22%; left: 10%; width: 60px; height: 60px; animation-delay: 2.6s; } .hb-bg-knot-3 { bottom: 22%; left: 10%; width: 60px; height: 60px; animation-delay: 2.6s; }
.hb-bg-knot-4 { bottom: 12%; right: 14%; width: 70px; height: 70px; color: rgba(240, 215, 140, 0.22); animation-delay: 1s; } .hb-bg-knot-4 { bottom: 12%; right: 14%; width: 70px; height: 70px; color: rgba(240, 215, 140, 0.22); animation-delay: 1s; }
.hb-bg-lantern { position: absolute; color: rgba(240, 215, 140, 0.35); width: 36px; height: 50px; animation: hb-swing 5.5s ease-in-out infinite; }
.hb-bg-lantern {
position: absolute;
color: rgba(240, 215, 140, 0.35);
width: 36px;
height: 50px;
animation: hb-swing 5.5s ease-in-out infinite;
}
.hb-bg-lantern-1 { top: 4%; left: 22%; } .hb-bg-lantern-1 { top: 4%; left: 22%; }
.hb-bg-lantern-2 { top: 5%; right: 20%; animation-delay: 1.4s; } .hb-bg-lantern-2 { top: 5%; right: 20%; animation-delay: 1.4s; }
.hb-bg-spark { .hb-bg-spark {
position: absolute; position: absolute; width: 6px; height: 6px; border-radius: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: radial-gradient(circle, var(--hb-gold-soft), transparent 70%); background: radial-gradient(circle, var(--hb-gold-soft), transparent 70%);
box-shadow: 0 0 10px rgba(240, 215, 140, 0.55); box-shadow: 0 0 10px rgba(240, 215, 140, 0.55); animation: hb-twinkle 2.8s ease-in-out infinite;
animation: hb-twinkle 2.8s ease-in-out infinite;
} }
.hb-bg-spark-1 { top: 18%; left: 30%; animation-delay: 0s; } .hb-bg-spark-1 { top: 18%; left: 30%; }
.hb-bg-spark-2 { top: 28%; right: 28%; animation-delay: 0.7s; } .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-3 { bottom: 30%; left: 26%; animation-delay: 1.3s; }
.hb-bg-spark-4 { bottom: 24%; right: 24%; animation-delay: 2s; } .hb-bg-spark-4 { bottom: 24%; right: 24%; animation-delay: 2s; }
.hb-loading { .hb-loading {
position: fixed; position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: var(--hb-red-deep); background: var(--hb-red-deep);
} }
.hb-seal { .hb-seal {
width: 88px; width: 88px; height: 88px; border-radius: 50%; border: 3px solid var(--hb-gold); color: var(--hb-gold);
height: 88px; display: flex; align-items: center; justify-content: center; font-size: 42px; font-weight: 700;
border-radius: 50%; animation: hb-seal-pop 1s ease both; box-shadow: 0 0 0 6px rgba(212, 175, 55, 0.15);
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(212, 175, 55, 0.15);
} }
.hb-shimmer { .hb-shimmer {
font-size: 28px; font-size: 28px; letter-spacing: 0.35em;
letter-spacing: 0.35em; background: linear-gradient(90deg, #8a6a1a, var(--hb-gold-soft), #8a6a1a); background-size: 200% 100%;
background: linear-gradient(90deg, #8a6a1a, var(--hb-gold-soft), #8a6a1a); -webkit-background-clip: text; background-clip: text; color: transparent; animation: hb-shimmer 1.2s linear infinite;
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: hb-shimmer 1.2s linear infinite;
} }
.hb-fade-dot { .hb-fade-dot {
width: 12px; width: 12px; height: 12px; border-radius: 50%; background: var(--hb-gold);
height: 12px;
border-radius: 50%;
background: var(--hb-gold);
animation: hb-fade-pulse 0.9s ease-in-out infinite; animation: hb-fade-pulse 0.9s ease-in-out infinite;
} }
.hb-enter { position: fixed; inset: 0; z-index: 30; pointer-events: none; }
.hb-enter {
position: fixed;
inset: 0;
z-index: 30;
pointer-events: none;
}
.hb-enter--curtain .hb-enter-panel { .hb-enter--curtain .hb-enter-panel {
position: absolute; position: absolute; top: 0; bottom: 0; width: 50%;
top: 0;
bottom: 0;
width: 50%;
background: linear-gradient(180deg, #8b0000, #5c0000); background: linear-gradient(180deg, #8b0000, #5c0000);
transition: transform 0.95s cubic-bezier(0.65, 0, 0.35, 1); transition: transform 0.95s cubic-bezier(0.65, 0, 0.35, 1);
} }
@@ -454,409 +232,33 @@ onUnmounted(() => {
.hb-enter--curtain .hb-enter-right { right: 0; border-left: 1px solid rgba(212, 175, 55, 0.35); } .hb-enter--curtain .hb-enter-right { right: 0; border-left: 1px solid rgba(212, 175, 55, 0.35); }
.hb-enter--curtain.is-done .hb-enter-left { transform: translateX(-105%); } .hb-enter--curtain.is-done .hb-enter-left { transform: translateX(-105%); }
.hb-enter--curtain.is-done .hb-enter-right { transform: translateX(105%); } .hb-enter--curtain.is-done .hb-enter-right { transform: translateX(105%); }
.hb-enter--fadeUp .hb-enter-veil, .hb-enter--fadeUp .hb-enter-veil,
.hb-enter--scaleIn .hb-enter-veil { .hb-enter--scaleIn .hb-enter-veil {
position: absolute; position: absolute; inset: 0; background: var(--hb-red-deep);
inset: 0;
background: var(--hb-red-deep);
transition: opacity 0.7s ease, transform 0.8s ease; transition: opacity 0.7s ease, transform 0.8s ease;
} }
.hb-enter--fadeUp.is-done .hb-enter-veil { .hb-enter--fadeUp.is-done .hb-enter-veil { opacity: 0; transform: translateY(-24px); }
opacity: 0; .hb-enter--scaleIn.is-done .hb-enter-veil { opacity: 0; transform: scale(1.08); }
transform: translateY(-24px);
}
.hb-enter--scaleIn.is-done .hb-enter-veil {
opacity: 0;
transform: scale(1.08);
}
.hb-stage {
position: relative;
z-index: 1;
width: min(420px, 100%);
opacity: 0;
transform: translateY(18px) scale(0.98);
transition: opacity 0.6s ease, transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.hb-stage.is-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.hb-frame {
position: relative;
border: 1.5px solid var(--hb-gold);
border-radius: 10px;
padding: 8px;
background:
linear-gradient(135deg, rgba(212, 175, 55, 0.18), transparent 40%),
linear-gradient(180deg, rgba(155, 0, 0, 0.4), rgba(90, 0, 0, 0.25));
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.35),
inset 0 0 0 1px rgba(240, 215, 140, 0.2);
}
.hb-frame-ornament {
position: absolute;
width: 34px;
height: 34px;
color: var(--hb-gold-soft);
z-index: 2;
pointer-events: none;
opacity: 0.9;
}
.hb-frame-ornament svg { width: 100%; height: 100%; display: block; }
.hb-frame-ornament-tl { top: 2px; left: 2px; }
.hb-frame-ornament-tr { top: 2px; right: 2px; transform: scaleX(-1); }
.hb-frame-ornament-bl { bottom: 2px; left: 2px; transform: scaleY(-1); }
.hb-frame-ornament-br { bottom: 2px; right: 2px; transform: scale(-1); }
.hb-inner {
position: relative;
border: 1.5px solid rgba(212, 175, 55, 0.85);
border-radius: 6px;
padding: 14px 14px 20px;
background:
linear-gradient(180deg, rgba(130, 0, 0, 0.58), rgba(110, 0, 0, 0.38)),
radial-gradient(circle at 90% 8%, rgba(255, 255, 255, 0.08), transparent 28%),
radial-gradient(circle at 10% 92%, rgba(196, 30, 58, 0.2), transparent 30%);
min-height: min(780px, calc(100dvh - 40px));
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.hb-inner::before {
content: '';
position: absolute;
inset: 5px;
border: 1px solid rgba(240, 215, 140, 0.35);
border-radius: 4px;
pointer-events: none;
z-index: 1;
clip-path: polygon(
0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px,
100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px)
);
}
.hb-inner::after {
content: '';
position: absolute;
inset: 10px;
pointer-events: none;
z-index: 1;
border: 1px dashed rgba(212, 175, 55, 0.18);
border-radius: 2px;
opacity: 0.8;
}
/* —— 纸面装饰 —— */
.hb-paper-decor {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.hb-paper-watermark {
position: absolute;
left: 50%;
top: 52%;
transform: translate(-50%, -50%);
font-size: min(220px, 58vw);
font-weight: 700;
color: rgba(196, 30, 58, 0.1);
line-height: 1;
letter-spacing: 0.05em;
text-shadow: 0 0 1px rgba(212, 175, 55, 0.15);
}
.hb-paper-knot {
position: absolute;
width: 42px;
height: 42px;
color: rgba(212, 175, 55, 0.45);
}
.hb-paper-knot-tl { top: 10px; left: 10px; }
.hb-paper-knot-tr { top: 10px; right: 10px; transform: scaleX(-1); color: rgba(240, 215, 140, 0.4); }
.hb-paper-knot-bl { bottom: 12px; left: 10px; transform: scaleY(-1); color: rgba(240, 215, 140, 0.38); }
.hb-paper-knot-br { bottom: 12px; right: 10px; transform: scale(-1); }
.hb-paper-cloud {
position: absolute;
width: 96px;
height: 42px;
color: rgba(212, 175, 55, 0.32);
}
.hb-paper-cloud-t { top: 8px; left: 50%; transform: translateX(-50%); }
.hb-paper-cloud-b { bottom: 8px; left: 50%; transform: translateX(-50%) scaleY(-1); color: rgba(196, 30, 58, 0.28); }
.hb-paper-xi {
position: absolute;
top: 46%;
font-size: 22px;
font-weight: 700;
color: rgba(196, 30, 58, 0.28);
text-shadow: 0 0 8px rgba(212, 175, 55, 0.15);
}
.hb-paper-xi-l { left: 8px; transform: rotate(-18deg); }
.hb-paper-xi-r { right: 8px; transform: rotate(18deg); }
.hb-paper-dot {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(212, 175, 55, 0.45);
box-shadow: 0 0 0 3px rgba(196, 30, 58, 0.12);
}
.hb-paper-dot-1 { top: 22%; left: 14px; }
.hb-paper-dot-2 { top: 22%; right: 14px; }
.hb-paper-dot-3 { bottom: 26%; left: 16px; }
.hb-paper-dot-4 { bottom: 26%; right: 16px; }
.hb-paper-side {
position: absolute;
top: 28%;
bottom: 28%;
width: 10px;
background:
repeating-linear-gradient(
180deg,
transparent 0 10px,
rgba(212, 175, 55, 0.22) 10px 11px,
transparent 11px 20px
);
opacity: 0.7;
}
.hb-paper-side-l {
left: 4px;
border-left: 1px solid rgba(212, 175, 55, 0.25);
}
.hb-paper-side-r {
right: 4px;
border-right: 1px solid rgba(212, 175, 55, 0.25);
}
.hb-hero-wrap,
.hb-names,
.hb-title-wrap,
.hb-body,
.hb-footer,
.hb-cta {
position: relative;
z-index: 2;
}
.hb-hero-wrap {
width: 100%;
opacity: 0;
transform: translateY(12px);
transition: opacity 0.55s ease, transform 0.55s ease;
margin-bottom: 14px;
}
.hb-hero-wrap.is-show {
opacity: 1;
transform: none;
}
.hb-hero {
position: relative;
border: 1.5px solid var(--hb-gold);
padding: 3px;
background: rgba(0, 0, 0, 0.15);
aspect-ratio: 16 / 11;
overflow: hidden;
}
.hb-hero-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.hb-hero-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(240, 215, 140, 0.55);
letter-spacing: 0.3em;
font-size: 13px;
background: rgba(0, 0, 0, 0.2);
}
.hb-xi {
position: absolute;
top: 10%;
font-size: 28px;
color: #c41e3a;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
font-weight: 700;
opacity: 0.92;
pointer-events: none;
}
.hb-xi-l { left: 8%; }
.hb-xi-r { right: 8%; }
.hb-names {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 28px;
margin-bottom: 8px;
opacity: 0;
transition: opacity 0.4s ease;
}
.hb-names.is-show { opacity: 1; }
.hb-name {
color: #f7efe0 !important;
font-size: 12px;
letter-spacing: 0.12em;
justify-content: center;
}
.hb-xi-circle {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--hb-gold);
color: var(--hb-gold);
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: rgba(155, 0, 0, 0.5);
}
.hb-title-wrap {
margin: 4px 0 12px;
min-height: 48px;
opacity: 0;
transition: opacity 0.35s ease;
}
.hb-title-wrap.is-show { opacity: 1; }
.hb-title {
color: var(--hb-gold-soft) !important;
font-size: clamp(30px, 9vw, 40px);
letter-spacing: 0.18em;
justify-content: center;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.hb-body {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 9px;
padding: 4px 4px 10px;
}
.hb-line {
color: var(--hb-gold-soft) !important;
font-size: 13px;
letter-spacing: 0.14em;
line-height: 1.55;
text-align: center;
justify-content: center;
}
.hb-cursor {
color: var(--hb-gold);
}
.hb-footer {
margin-top: 8px;
min-height: 24px;
opacity: 0;
transition: opacity 0.4s ease;
}
.hb-footer.is-show { opacity: 1; }
.hb-footer-text {
color: #f7efe0 !important;
font-size: 12px;
letter-spacing: 0.2em;
justify-content: center;
}
.hb-cta {
margin-top: 16px;
opacity: 0;
transform: translateY(8px);
animation: hb-cta-in 0.5s ease forwards;
}
.hb-cta.is-show { opacity: 1; }
.hb-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: 8px 22px;
border-radius: 999px;
letter-spacing: 0.28em;
font-size: 13px;
cursor: pointer;
}
.hb-cta-btn:active { transform: scale(0.98); }
.hb-disabled { .hb-disabled {
position: fixed; position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
inset: 0; align-items: center; justify-content: center; gap: 16px;
z-index: 50; background: rgba(80, 0, 0, 0.92); color: var(--hb-cream);
display: flex; }
flex-direction: column; .hb-cta-btn {
align-items: center; appearance: none; border: 1px solid var(--hb-gold);
justify-content: center; background: linear-gradient(180deg, rgba(212, 175, 55, 0.22), rgba(212, 175, 55, 0.08));
gap: 16px; color: var(--hb-gold-soft); padding: 8px 22px; border-radius: 999px;
background: rgba(80, 0, 0, 0.92); letter-spacing: 0.28em; font-size: 13px; cursor: pointer;
color: var(--hb-cream);
} }
@keyframes hb-seal-pop { @keyframes hb-seal-pop {
0% { opacity: 0; transform: scale(0.4) rotate(-12deg); } 0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
60% { opacity: 1; transform: scale(1.08) rotate(2deg); } 60% { opacity: 1; transform: scale(1.08) rotate(2deg); }
100% { opacity: 1; transform: scale(1) rotate(0); } 100% { opacity: 1; transform: scale(1) rotate(0); }
} }
@keyframes hb-shimmer { @keyframes hb-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
0% { background-position: 100% 0; } @keyframes hb-fade-pulse { 0%, 100% { opacity: 0.25; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.1); } }
100% { background-position: -100% 0; } @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-fade-pulse { @keyframes hb-swing { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }
0%, 100% { opacity: 0.25; transform: scale(0.85); } @keyframes hb-twinkle { 0%, 100% { opacity: 0.25; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1.25); } }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes hb-cta-in {
to { opacity: 1; transform: none; }
}
@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> </style>

View File

@@ -0,0 +1,342 @@
<template>
<div class="hb-stage" :class="{ 'is-visible': ready }">
<div class="hb-frame">
<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>
<div class="hb-frame-ornament hb-frame-ornament-tr" aria-hidden="true">
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg>
</div>
<div class="hb-frame-ornament hb-frame-ornament-bl" aria-hidden="true">
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg>
</div>
<div class="hb-frame-ornament hb-frame-ornament-br" aria-hidden="true">
<svg viewBox="0 0 48 48"><use href="#hb-icon-corner" /></svg>
</div>
<div class="hb-inner">
<div class="hb-paper-decor" aria-hidden="true">
<span class="hb-paper-watermark"></span>
<svg class="hb-paper-knot hb-paper-knot-tl" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-knot hb-paper-knot-tr" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-knot hb-paper-knot-bl" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-knot hb-paper-knot-br" viewBox="0 0 64 64"><use href="#hb-icon-knot" /></svg>
<svg class="hb-paper-cloud hb-paper-cloud-t" viewBox="0 0 80 36"><use href="#hb-icon-cloud" /></svg>
<svg class="hb-paper-cloud hb-paper-cloud-b" viewBox="0 0 80 36"><use href="#hb-icon-cloud" /></svg>
<span class="hb-paper-xi hb-paper-xi-l"></span>
<span class="hb-paper-xi hb-paper-xi-r"></span>
<span class="hb-paper-dot hb-paper-dot-1"></span>
<span class="hb-paper-dot hb-paper-dot-2"></span>
<span class="hb-paper-dot hb-paper-dot-3"></span>
<span class="hb-paper-dot hb-paper-dot-4"></span>
<div class="hb-paper-side hb-paper-side-l"></div>
<div class="hb-paper-side hb-paper-side-r"></div>
</div>
<div class="hb-hero-wrap" :class="{ 'is-show': stage >= 1 }">
<div class="hb-hero">
<img v-if="cfg.heroImg" :src="cfg.heroImg" alt="" class="hb-hero-img" />
<div v-else class="hb-hero-placeholder">婚礼照片</div>
<span class="hb-xi hb-xi-l"></span>
<span class="hb-xi hb-xi-r"></span>
</div>
</div>
<div class="hb-names" :class="{ 'is-show': stage >= 2 }">
<BlurText
v-if="stage >= 2"
:text="nameLeft"
:active="stage >= 2"
animate-by="letters"
:delay="40"
class-name="hb-name"
@animation-complete="namesRightReady = true"
/>
<span class="hb-xi-circle"></span>
<BlurText
v-if="namesRightReady"
:text="nameRight"
:active="namesRightReady"
animate-by="letters"
:delay="40"
class-name="hb-name"
@animation-complete="bumpStage(3)"
/>
</div>
<div class="hb-title-wrap" :class="{ 'is-show': stage >= 3 }">
<BlurText
v-if="stage >= 3"
:text="cfg.title"
:active="stage >= 3"
animate-by="letters"
direction="bottom"
:delay="70"
:step-duration="0.4"
class-name="hb-title calligraphy-strong"
@animation-complete="bumpStage(4)"
/>
</div>
<div class="hb-body">
<template v-for="(line, i) in cfg.lines" :key="`line-${i}-${line}`">
<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="hb-cursor"
:typing-speed="48"
:initial-delay="80"
class-name="hb-line"
@animation-complete="onLineDone(i)"
/>
<BlurText
v-else-if="lineStage > i"
:text="line"
:active="lineStage > i"
animate-by="words"
:delay="90"
class-name="hb-line"
@animation-complete="onLineDone(i)"
/>
</template>
</div>
<div class="hb-footer" :class="{ 'is-show': stage >= 5 }">
<BlurText
v-if="stage >= 5"
:text="cfg.footer"
:active="stage >= 5"
animate-by="letters"
:delay="35"
class-name="hb-footer-text"
@animation-complete="bumpStage(6)"
/>
</div>
<div v-if="cfg.showInviteButton && stage >= 6" class="hb-cta is-show">
<button type="button" class="hb-cta-btn" @click="$emit('invite')">
{{ cfg.inviteButtonText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
const props = defineProps({
cfg: { type: Object, required: true },
ready: { type: Boolean, default: false },
})
defineEmits(['invite'])
const stage = ref(0)
const lineStage = ref(0)
const namesRightReady = ref(false)
let timers = []
const nameLeft = computed(() => `${props.cfg.sonLabel || ''}${props.cfg.sonName || ''}`)
const nameRight = computed(() => `${props.cfg.daughterInLawLabel || ''}${props.cfg.daughterInLawName || ''}`)
function later(fn, ms) {
const id = setTimeout(fn, ms)
timers.push(id)
}
function bumpStage(n) {
if (stage.value < n) stage.value = n
if (n === 4) {
if (!props.cfg.lines?.length) {
later(() => bumpStage(5), 200)
return
}
lineStage.value = 1
}
}
function onLineDone(i) {
if (i + 1 >= (props.cfg.lines?.length || 0)) {
bumpStage(5)
return
}
if (lineStage.value === i + 1) lineStage.value = i + 2
}
watch(
() => props.ready,
(v) => {
if (!v) return
later(() => { stage.value = 1 }, 200)
later(() => { stage.value = 2 }, 700)
},
{ immediate: true },
)
</script>
<style scoped>
.hb-stage {
position: relative;
z-index: 1;
width: min(420px, 100%);
opacity: 0;
transform: translateY(18px) scale(0.98);
transition: opacity 0.6s ease, transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.hb-stage.is-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.hb-frame {
position: relative;
border: 1.5px solid var(--hb-gold);
border-radius: 10px;
padding: 8px;
background:
linear-gradient(135deg, rgba(212, 175, 55, 0.18), transparent 40%),
linear-gradient(180deg, rgba(155, 0, 0, 0.4), rgba(90, 0, 0, 0.25));
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(240, 215, 140, 0.2);
}
.hb-frame-ornament {
position: absolute;
width: 34px;
height: 34px;
color: var(--hb-gold-soft);
z-index: 2;
pointer-events: none;
opacity: 0.9;
}
.hb-frame-ornament svg { width: 100%; height: 100%; display: block; }
.hb-frame-ornament-tl { top: 2px; left: 2px; }
.hb-frame-ornament-tr { top: 2px; right: 2px; transform: scaleX(-1); }
.hb-frame-ornament-bl { bottom: 2px; left: 2px; transform: scaleY(-1); }
.hb-frame-ornament-br { bottom: 2px; right: 2px; transform: scale(-1); }
.hb-inner {
position: relative;
border: 1.5px solid rgba(212, 175, 55, 0.85);
border-radius: 6px;
padding: 14px 14px 20px;
background:
linear-gradient(180deg, rgba(130, 0, 0, 0.58), rgba(110, 0, 0, 0.38)),
radial-gradient(circle at 90% 8%, rgba(255, 255, 255, 0.08), transparent 28%),
radial-gradient(circle at 10% 92%, rgba(196, 30, 58, 0.2), transparent 30%);
min-height: min(780px, calc(100dvh - 40px));
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.hb-inner::before {
content: '';
position: absolute;
inset: 5px;
border: 1px solid rgba(240, 215, 140, 0.35);
border-radius: 4px;
pointer-events: none;
z-index: 1;
clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px));
}
.hb-inner::after {
content: '';
position: absolute;
inset: 10px;
pointer-events: none;
z-index: 1;
border: 1px dashed rgba(212, 175, 55, 0.18);
border-radius: 2px;
}
.hb-paper-decor { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.hb-paper-watermark {
position: absolute; left: 50%; top: 52%; transform: translate(-50%, -50%);
font-size: min(220px, 58vw); font-weight: 700; color: rgba(196, 30, 58, 0.1); line-height: 1;
}
.hb-paper-knot { position: absolute; width: 42px; height: 42px; color: rgba(212, 175, 55, 0.45); }
.hb-paper-knot-tl { top: 10px; left: 10px; }
.hb-paper-knot-tr { top: 10px; right: 10px; transform: scaleX(-1); }
.hb-paper-knot-bl { bottom: 12px; left: 10px; transform: scaleY(-1); }
.hb-paper-knot-br { bottom: 12px; right: 10px; transform: scale(-1); }
.hb-paper-cloud { position: absolute; width: 96px; height: 42px; color: rgba(212, 175, 55, 0.32); }
.hb-paper-cloud-t { top: 8px; left: 50%; transform: translateX(-50%); }
.hb-paper-cloud-b { bottom: 8px; left: 50%; transform: translateX(-50%) scaleY(-1); color: rgba(196, 30, 58, 0.28); }
.hb-paper-xi { position: absolute; top: 46%; font-size: 22px; font-weight: 700; color: rgba(196, 30, 58, 0.28); }
.hb-paper-xi-l { left: 8px; transform: rotate(-18deg); }
.hb-paper-xi-r { right: 8px; transform: rotate(18deg); }
.hb-paper-dot {
position: absolute; width: 5px; height: 5px; border-radius: 50%;
background: rgba(212, 175, 55, 0.45); box-shadow: 0 0 0 3px rgba(196, 30, 58, 0.12);
}
.hb-paper-dot-1 { top: 22%; left: 14px; }
.hb-paper-dot-2 { top: 22%; right: 14px; }
.hb-paper-dot-3 { bottom: 26%; left: 16px; }
.hb-paper-dot-4 { bottom: 26%; right: 16px; }
.hb-paper-side {
position: absolute; top: 28%; bottom: 28%; width: 10px;
background: repeating-linear-gradient(180deg, transparent 0 10px, rgba(212, 175, 55, 0.22) 10px 11px, transparent 11px 20px);
opacity: 0.7;
}
.hb-paper-side-l { left: 4px; border-left: 1px solid rgba(212, 175, 55, 0.25); }
.hb-paper-side-r { right: 4px; border-right: 1px solid rgba(212, 175, 55, 0.25); }
.hb-hero-wrap, .hb-names, .hb-title-wrap, .hb-body, .hb-footer, .hb-cta { position: relative; z-index: 2; }
.hb-hero-wrap {
width: 100%; opacity: 0; transform: translateY(12px);
transition: opacity 0.55s ease, transform 0.55s ease; margin-bottom: 14px;
}
.hb-hero-wrap.is-show { opacity: 1; transform: none; }
.hb-hero {
position: relative; border: 1.5px solid var(--hb-gold); padding: 3px;
background: rgba(0, 0, 0, 0.15); aspect-ratio: 16 / 11; overflow: hidden;
}
.hb-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hb-hero-placeholder {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
color: rgba(240, 215, 140, 0.55); letter-spacing: 0.3em; font-size: 13px; background: rgba(0, 0, 0, 0.2);
}
.hb-xi {
position: absolute; top: 10%; font-size: 28px; color: #c41e3a; font-weight: 700;
opacity: 0.92; pointer-events: none;
}
.hb-xi-l { left: 8%; }
.hb-xi-r { right: 8%; }
.hb-names {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 28px; margin-bottom: 8px; opacity: 0; transition: opacity 0.4s ease;
}
.hb-names.is-show { opacity: 1; }
.hb-name { color: #f7efe0 !important; font-size: 12px; letter-spacing: 0.12em; justify-content: center; }
.hb-xi-circle {
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--hb-gold);
color: var(--hb-gold); font-size: 11px; display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0; background: rgba(155, 0, 0, 0.5);
}
.hb-title-wrap { margin: 4px 0 12px; min-height: 48px; opacity: 0; transition: opacity 0.35s ease; }
.hb-title-wrap.is-show { opacity: 1; }
.hb-title {
color: var(--hb-gold-soft) !important; font-size: clamp(30px, 9vw, 40px);
letter-spacing: 0.18em; justify-content: center; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.hb-body {
width: 100%; flex: 1; display: flex; flex-direction: column; align-items: center; gap: 9px; padding: 4px 4px 10px;
}
.hb-line {
color: var(--hb-gold-soft) !important; font-size: 13px; letter-spacing: 0.14em;
line-height: 1.55; text-align: center; justify-content: center;
}
.hb-cursor { color: var(--hb-gold); }
.hb-footer { margin-top: 8px; min-height: 24px; opacity: 0; transition: opacity 0.4s ease; }
.hb-footer.is-show { opacity: 1; }
.hb-footer-text { color: #f7efe0 !important; font-size: 12px; letter-spacing: 0.2em; justify-content: center; }
.hb-cta { margin-top: 16px; opacity: 0; transform: translateY(8px); animation: hb-cta-in 0.5s ease forwards; }
.hb-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: 8px 22px; border-radius: 999px;
letter-spacing: 0.28em; font-size: 13px; cursor: pointer;
}
@keyframes hb-cta-in { to { opacity: 1; transform: none; } }
</style>

View File

@@ -0,0 +1,337 @@
<template>
<div class="sp" :class="{ 'is-visible': ready }">
<div class="sp-card">
<section class="sp-left">
<div class="sp-photo" :class="{ 'is-show': stage >= 1 }">
<img v-if="cfg.heroImg" :src="cfg.heroImg" alt="" />
<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" />
<BlurText :text="cfg.subSlogan2 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub" />
</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"
@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"
@animation-complete="onLineDone(i)"
/>
<BlurText
v-else-if="lineStage > i"
:text="line"
:active="lineStage > i"
animate-by="words"
:delay="70"
class-name="sp-line"
@animation-complete="onLineDone(i)"
/>
</template>
</div>
<div class="sp-couple" :class="{ 'is-show': stage >= 5 }">
<div class="sp-person">
<span class="sp-role">新郎</span>
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name" />
</div>
<span class="sp-xi-mid"></span>
<div class="sp-person">
<span class="sp-role">新娘</span>
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name" @animation-complete="bumpStage(6)" />
</div>
</div>
<div class="sp-meta" :class="{ 'is-show': stage >= 6 }">
<div class="sp-meta-title">新婚喜宴时间</div>
<div class="sp-meta-val">{{ cfg.dateText }}</div>
<div class="sp-meta-sub">{{ cfg.lunarText }}</div>
<div class="sp-meta-title mt">地点</div>
<div class="sp-meta-val">{{ 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">
<button type="button" class="sp-cta-btn" @click="$emit('invite')">
{{ cfg.inviteButtonText }}
</button>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
const props = defineProps({
cfg: { type: Object, required: true },
ready: { type: Boolean, default: false },
})
defineEmits(['invite'])
const stage = ref(0)
const lineStage = ref(0)
let timers = []
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 || '')
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 },
)
</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-card {
display: flex;
flex-direction: column;
border: 1.5px solid var(--hb-gold);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35);
min-height: min(720px, calc(100dvh - 32px));
background: #8b0000;
}
@media (min-width: 720px) {
.sp-card { flex-direction: row; min-height: min(560px, calc(100dvh - 40px)); }
.sp-left { width: 46%; }
.sp-right { width: 54%; }
}
.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; }
.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; justify-content: center; gap: 14px;
margin: 6px 0 14px; opacity: 0; transition: opacity 0.4s ease;
}
.sp-couple.is-show { opacity: 1; }
.sp-person { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
.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; }
.sp-xi-mid {
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--hb-gold);
display: inline-flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--hb-gold); background: rgba(0, 0, 0, 0.15);
}
.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;
}
</style>