1. 修复海报图片

This commit is contained in:
李琦
2026-08-04 08:19:30 +08:00
parent ef1e2ee811
commit 078e621783
7 changed files with 332 additions and 50 deletions

View File

@@ -22,7 +22,9 @@ var (
var configSectionKeys = map[string][]string{ var configSectionKeys = map[string][]string{
"basic": { "basic": {
"groom", "bride", "date", "lunar", "calendarDate", "hotel", "address", "heroImg", "endImg", "groom", "bride", "date", "lunar", "calendarDate", "hotel", "address",
"heroImg", "heroImgResized", "heroImgResizedMeta",
"endImg", "endImgResized", "endImgResizedMeta",
}, },
"copy": { "copy": {
"formalText1", "formalText2", "formalPageHeight", "formalText1", "formalText2", "formalPageHeight",

View File

@@ -23,7 +23,7 @@ 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"(女方)或 "3"回门宴);无法识别返回空 // resolveSide 返回 "1"(男方)、"2"(女方)或 "3"新人);无法识别返回空
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 {
@@ -31,7 +31,7 @@ func resolveSide(raw string) string {
return "1" return "1"
case "2", "female", "nv", "f", "bride": case "2", "female", "nv", "f", "bride":
return "2" return "2"
case "3", "hm", "hmy", "return", "huimen": case "3", "xr", "hm", "hmy", "return", "huimen", "couple", "xinren", "新人":
return "3" return "3"
default: default:
return "" return ""
@@ -156,7 +156,7 @@ func normalizeBundle(raw map[string]interface{}) map[string]interface{} {
if f := takeSide(raw, "2", "female"); len(f) > 0 { if f := takeSide(raw, "2", "female"); len(f) > 0 {
out["2"] = normalizeSideLayouts(f) out["2"] = normalizeSideLayouts(f)
} }
if h := takeSide(raw, "3", "hm", "hmy"); len(h) > 0 { if h := takeSide(raw, "3", "xr", "hm", "hmy"); len(h) > 0 {
out["3"] = normalizeSideLayouts(h) out["3"] = normalizeSideLayouts(h)
} }
return out return out

View File

@@ -1,9 +1,8 @@
/** 海报 side1=男方(/hb-n)2=女方(/hb-nv)3=回门宴(/hb-hm) */ /** 海报 side1=男方(/hb-n)2=女方(/hb-nv)3=新人模板(/hb-xr) */
export const POSTER_SIDES = [ export const POSTER_SIDES = [
{ key: 1, keyStr: '1', route: '/hb-n', label: '男方', short: 'n' }, { key: 1, keyStr: '1', route: '/hb-n', label: '男方', short: 'n' },
{ key: 2, keyStr: '2', route: '/hb-nv', label: '女方', short: 'nv' }, { key: 2, keyStr: '2', route: '/hb-nv', label: '女方', short: 'nv' },
{ key: 3, keyStr: '3', route: '/hb-hm', label: '回门宴', short: 'hm' }, { key: 3, keyStr: '3', route: '/hb-xr', label: '新人模板', short: 'xr' },
] ]
/** 布局1=竖版2=横版URL 后缀 /1 /2 */ /** 布局1=竖版2=横版URL 后缀 /1 /2 */
@@ -118,7 +117,7 @@ export function resolvePosterSide(input) {
if (input === 2 || input === '2') return 2 if (input === 2 || input === '2') return 2
if (input === 3 || input === '3') return 3 if (input === 3 || input === '3') return 3
const v = String(input || '').trim().toLowerCase() const v = String(input || '').trim().toLowerCase()
if (v === 'hm' || v === 'hmy' || v === 'return' || v === 'huimen') return 3 if (v === 'xr' || v === 'hm' || v === 'hmy' || v === 'return' || v === 'huimen' || v === 'xinren' || v === 'couple') return 3
if (v === 'female' || v === 'nv' || v === 'f' || v === 'bride') return 2 if (v === 'female' || v === 'nv' || v === 'f' || v === 'bride') return 2
if (v === 'male' || v === 'n' || v === 'm' || v === 'groom') return 1 if (v === 'male' || v === 'n' || v === 'm' || v === 'groom') return 1
return 0 return 0
@@ -264,23 +263,23 @@ export function defaultSide2PosterConfig() {
} }
} }
/** 回门宴(3) 默认9月7日 */ /** 新人模板(3) 默认 */
export function defaultSide3PosterConfig() { export function defaultSide3PosterConfig() {
return { return {
...baseFields(3), ...baseFields(3),
side: 3, side: 3,
template: 'classic', template: 'classic',
title: '回门宴邀请函', title: '婚礼邀请函',
pageTitle: '💕李琦❤️李逸烁 · 回门宴邀请', pageTitle: '💕李琦❤️李逸烁 · 新人婚礼邀请',
pageDescription: '💍公历2026年9月7日农历七月廿六· 李琦 & 李逸烁 回门宴,诚邀您光临。', pageDescription: '💍公历2026年9月7日农历七月廿六· 李琦 & 李逸烁 结婚典礼,诚邀您光临。',
footer: '—回门宴请 ♥ 恭请光临—', footer: '—诚挚邀请 ♥ 恭请光临—',
sonLabel: '新郎:', sonLabel: '新郎:',
sonName: '李琦', sonName: '李琦',
daughterInLawLabel: '新娘:', daughterInLawLabel: '新娘:',
daughterInLawName: '李逸烁', daughterInLawName: '李逸烁',
groomName: '李琦', groomName: '李琦',
brideName: '李逸烁', brideName: '李逸烁',
verticalSlogan: '回门之喜', verticalSlogan: '喜结连理',
subSlogan1: '琴瑟和鸣', subSlogan1: '琴瑟和鸣',
subSlogan2: '永结同心', subSlogan2: '永结同心',
dateText: '2026年9月7日 星期一', dateText: '2026年9月7日 星期一',
@@ -290,11 +289,11 @@ export function defaultSide3PosterConfig() {
loadingEffect: 'redSeal', loadingEffect: 'redSeal',
lines: [ lines: [
'各位亲朋好友', '各位亲朋好友',
'新婚燕尔 回门宴请', '良辰吉日 新人成婚',
'良辰已定 吉日待访', '良辰已定 吉日待访',
'兹定于2026年9月7日 星期一', '兹定于2026年9月7日 星期一',
'农历七月廿六 中午11:08分', '农历七月廿六 中午11:08分',
'为新婚夫妇举办回门宴', '为新婚夫妇举办结婚典礼',
'敬备薄酒 诚邀您携家人光临', '敬备薄酒 诚邀您携家人光临',
'地址:山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼', '地址:山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼',
'李琦 & 李逸烁 敬邀', '李琦 & 李逸烁 敬邀',

View File

@@ -33,15 +33,32 @@ const routes = [
component: () => import('@/views/hb/index.vue'), component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 2 }, meta: { posterSide: 2 },
}, },
{
path: '/hb-xr',
redirect: '/hb-xr/1',
},
{
path: '/hb-xr/:layout(1|2)',
name: 'PosterCouple',
component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 3 },
},
// 旧路径兼容
{ {
path: '/hb-hm', path: '/hb-hm',
redirect: '/hb-hm/1', redirect: '/hb-xr/1',
}, },
{ {
path: '/hb-hm/:layout(1|2)', path: '/hb-hm/:layout(1|2)',
name: 'PosterReturnBanquet', redirect: (to) => `/hb-xr/${to.params.layout || 1}`,
component: () => import('@/views/hb/index.vue'), },
meta: { posterSide: 3 }, {
path: '/xr',
redirect: '/hb-xr/1',
},
{
path: '/xr/:layout(1|2)',
redirect: (to) => `/hb-xr/${to.params.layout || 1}`,
}, },
{ {
path: '/lijin', path: '/lijin',

View File

@@ -653,14 +653,14 @@
</a-card> </a-card>
</section> </section>
<!-- 海报独立 poster_configs1 男方 /hb-n2 女方 /hb-nv3 回门宴 /hb-hm --> <!-- 海报独立 poster_configs1 男方 /hb-n2 女方 /hb-nv3 新人模板 /hb-xr -->
<section v-if="activeTab === 'poster'"> <section v-if="activeTab === 'poster'">
<a-card :bordered="false" class="!shadow-sm"> <a-card :bordered="false" class="!shadow-sm">
<a-alert <a-alert
type="info" type="info"
show-icon show-icon
class="mb-4" class="mb-4"
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2回门 /hb-hm/1|/2。" message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2新人模板 /hb-xr/1|/2。"
/> />
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2"> <a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" /> <a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
@@ -862,11 +862,17 @@
<a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" /> <a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
</a-form-item> </a-form-item>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="新郎"> <a-form-item :label="posterNameLabels.left">
<a-input v-model:value="posterCfg.groomName" /> <div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
</div>
</a-form-item> </a-form-item>
<a-form-item label="新娘"> <a-form-item :label="posterNameLabels.right">
<a-input v-model:value="posterCfg.brideName" /> <div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
</div>
</a-form-item> </a-form-item>
<a-form-item label="喜宴时间"> <a-form-item label="喜宴时间">
<a-input v-model:value="posterCfg.dateText" /> <a-input v-model:value="posterCfg.dateText" />
@@ -882,20 +888,47 @@
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" /> <a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
</a-form-item> </a-form-item>
</template> </template>
<div class="flex gap-2 flex-wrap">
<a-button type="primary" :loading="posterSaving" @click="onSavePosterConfig"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
<i class="fa-solid fa-floppy-disk mr-1"></i>保存海报配置
</a-button>
<a-button @click="openPosterPreview(posterSideTab, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterPreviewPath }} 预览
</a-button>
</div>
</a-form> </a-form>
</a-card> </a-card>
<div class="poster-action-bar-spacer" aria-hidden="true"></div>
</section> </section>
<div v-if="activeTab === 'poster'" class="poster-action-bar">
<div class="poster-action-bar-inner">
<div class="poster-action-bar-meta">
<span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
<span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
</div>
<div class="poster-action-bar-btns">
<a-button
v-if="posterUndoSnapshot"
size="small"
@click="undoPosterImport"
>
<i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
</a-button>
<a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
<i class="fa-solid fa-copy mr-1"></i>复制
</a-button>
<a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
<i class="fa-solid fa-file-import mr-1"></i>导入
</a-button>
<a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>预览
</a-button>
<a-button
type="primary"
size="small"
:loading="posterSaving"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="onSavePosterConfig"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>保存
</a-button>
</div>
</div>
</div>
<section v-if="activeTab === 'photos'"> <section v-if="activeTab === 'photos'">
<div class="admin-panel !p-4 mb-3"> <div class="admin-panel !p-4 mb-3">
<div class="flex justify-between items-center gap-3 flex-wrap"> <div class="flex justify-between items-center gap-3 flex-wrap">
@@ -1419,7 +1452,7 @@ import {
metaHasFileSize, metaHasFileSize,
} from '@/composables/resizeImage' } from '@/composables/resizeImage'
import { import {
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS, LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey, HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
posterSideMeta, posterLayoutMeta, posterRoute, posterSideMeta, posterLayoutMeta, posterRoute,
@@ -1616,6 +1649,12 @@ const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1') const posterSideTab = ref('1')
const posterLayoutTab = ref('1') const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle()) const posterBundle = reactive(defaultPosterBundle())
const posterImporting = ref(false)
const posterCopying = ref(false)
/** 本会话最近一次复制的模板,导入时优先/兜底使用 */
let posterTemplateCache = null
/** 导入前快照,用于撤回 { s, l, config } */
const posterUndoSnapshot = ref(null)
const posterCfg = computed(() => { const posterCfg = computed(() => {
const s = sideKey(posterSideTab.value) const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value) const l = layoutKey(posterLayoutTab.value)
@@ -1737,7 +1776,7 @@ const posterNameLabels = computed(() => {
return { left: '女儿称谓 / 姓名', right: '女婿称谓 / 姓名', leftPh: '女儿:', rightPh: '女婿:' } return { left: '女儿称谓 / 姓名', right: '女婿称谓 / 姓名', leftPh: '女儿:', rightPh: '女婿:' }
} }
if (s === '3') { if (s === '3') {
return { left: '新称谓 / 姓名', right: '新称谓 / 姓名', leftPh: '新郎:', rightPh: '新娘:' } return { left: '新人左侧称谓 / 姓名', right: '新人右侧称谓 / 姓名', leftPh: '新郎:', rightPh: '新娘:' }
} }
return { left: '儿子称谓 / 姓名', right: '儿媳称谓 / 姓名', leftPh: '儿子:', rightPh: '儿媳:' } return { left: '儿子称谓 / 姓名', right: '儿媳称谓 / 姓名', leftPh: '儿子:', rightPh: '儿媳:' }
}) })
@@ -2026,9 +2065,17 @@ async function batchResizeBasicImages() {
console.warn('[resize-basic]', slot.key, err) console.warn('[resize-basic]', slot.key, err)
} }
} }
if (fail === 0) message.success(`已生成 ${ok} 张展示图`) if (ok > 0) {
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`) try {
else message.error('缩放上传全部失败,请确认原图可跨域读取') await saveConfigSection('basic', pickSectionData('basic'))
if (fail === 0) message.success(`已生成并保存 ${ok} 张展示图`)
else message.warning(`成功 ${ok} 张并已保存,失败 ${fail}`)
} catch (e) {
message.warning(`已生成 ${ok} 张展示图,但自动保存失败:${e?.message || '请手动保存'}`)
}
} else {
message.error('缩放上传全部失败,请确认原图可跨域读取')
}
} finally { } finally {
resizingBasic.value = false resizingBasic.value = false
resizeProgress.value = '' resizeProgress.value = ''
@@ -2285,6 +2332,7 @@ async function onSavePosterConfig() {
posterBundle['1'] = bundle['1'] posterBundle['1'] = bundle['1']
posterBundle['2'] = bundle['2'] posterBundle['2'] = bundle['2']
posterBundle['3'] = bundle['3'] posterBundle['3'] = bundle['3']
posterUndoSnapshot.value = null
message.success('海报配置已保存') message.success('海报配置已保存')
} catch (e) { } catch (e) {
message.error(e?.message || '海报配置保存失败') message.error(e?.message || '海报配置保存失败')
@@ -2293,6 +2341,141 @@ async function onSavePosterConfig() {
} }
} }
function buildPosterTemplatePayload() {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const slot = ensurePosterSlot(s, l)
return {
version: 1,
kind: 'poster-slot',
side: Number(s),
layout: Number(l),
sideLabel: posterSideMeta(s).label,
layoutLabel: posterLayoutMeta(l).label,
route: posterRoute(s, l),
config: JSON.parse(JSON.stringify(slot)),
}
}
async function copyPosterTemplate() {
posterCopying.value = true
try {
const payload = buildPosterTemplatePayload()
posterTemplateCache = payload
const text = JSON.stringify(payload, null, 2)
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
message.success(`已复制「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}`)
} catch (e) {
message.error(e?.message || '复制失败,请检查浏览器剪贴板权限')
} finally {
posterCopying.value = false
}
}
function extractPosterImportConfig(parsed) {
if (!parsed || typeof parsed !== 'object') return null
if (parsed.kind === 'poster-slot' && parsed.config && typeof parsed.config === 'object') {
return parsed.config
}
if (parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)) {
return parsed.config
}
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const sideObj = parsed[s] || parsed[Number(s)]
if (sideObj && typeof sideObj === 'object') {
const slot = sideObj[l] || sideObj[Number(l)]
if (slot && typeof slot === 'object') return slot
}
if ('template' in parsed || 'heroImg' in parsed || 'lines' in parsed || 'sonName' in parsed || 'groomName' in parsed) {
return parsed
}
return null
}
async function readPosterImportSource() {
// 1) 剪贴板 2) 本会话刚复制的缓存
try {
if (navigator?.clipboard?.readText) {
const clip = String(await navigator.clipboard.readText() || '').trim()
if (clip) {
try {
const parsed = JSON.parse(clip)
if (extractPosterImportConfig(parsed)) return parsed
} catch { /* not json */ }
}
}
} catch { /* no permission */ }
if (posterTemplateCache && extractPosterImportConfig(posterTemplateCache)) {
return posterTemplateCache
}
return null
}
function applyImportedConfig(rawCfg) {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
posterUndoSnapshot.value = {
s,
l,
config: JSON.parse(JSON.stringify(ensurePosterSlot(s, l))),
}
const next = normalizePosterConfig(rawCfg, s, l)
next.side = Number(s)
next.layout = Number(l)
if (next.sonName && !next.groomName) next.groomName = next.sonName
if (next.daughterInLawName && !next.brideName) next.brideName = next.daughterInLawName
if (next.groomName && !next.sonName) next.sonName = next.groomName
if (next.brideName && !next.daughterInLawName) next.daughterInLawName = next.brideName
posterBundle[s][l] = next
}
async function importPosterTemplateDirect() {
posterImporting.value = true
try {
const parsed = await readPosterImportSource()
if (!parsed) {
message.warning('请先复制模板,再点导入')
return
}
const rawCfg = extractPosterImportConfig(parsed)
if (!rawCfg) {
message.error('无法识别的模板数据')
return
}
applyImportedConfig(rawCfg)
message.success(`已导入到「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」,可撤回或保存`)
} catch (e) {
message.error(e?.message || '导入失败')
} finally {
posterImporting.value = false
}
}
function undoPosterImport() {
const snap = posterUndoSnapshot.value
if (!snap?.config) {
message.info('没有可撤回的导入')
return
}
posterSideTab.value = snap.s
posterLayoutTab.value = snap.l
posterBundle[snap.s][snap.l] = snap.config
posterUndoSnapshot.value = null
message.success('已撤回导入')
}
function pickSectionData(section) { function pickSectionData(section) {
const keys = SECTION_KEYS[section] || [] const keys = SECTION_KEYS[section] || []
const data = {} const data = {}
@@ -3514,4 +3697,57 @@ onUnmounted(() => {
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
.poster-action-bar-spacer {
height: 72px;
}
.poster-action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(168, 140, 107, 0.2);
box-shadow: 0 -8px 24px rgba(88, 68, 42, 0.08);
}
.poster-action-bar-inner {
max-width: 1220px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.admin-shell--top ~ .poster-action-bar .poster-action-bar-inner,
.admin-shell--top .poster-action-bar-inner {
max-width: 80vw;
}
.poster-action-bar-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: 12px;
color: #5c5348;
}
.poster-action-bar-path {
font-size: 11px;
color: #8A8680;
}
.poster-action-bar-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-left: auto;
}
@media (max-width: 720px) {
.poster-action-bar-meta { width: 100%; }
.poster-action-bar-btns { width: 100%; justify-content: stretch; }
.poster-action-bar-btns :deep(.ant-btn) { flex: 1 1 auto; }
}
</style> </style>

View File

@@ -127,7 +127,7 @@
</div> </div>
</div> </div>
<!-- PC右下角固定不占版心空间保存时会把二维码打进海报图 --> <!-- PC右下角固定 ?qr=1 时展示二维码保存海报时也会打进图里 -->
<aside <aside
v-if="isPc && phase === 'content' && contentVisible && cfg.enabled" v-if="isPc && phase === 'content' && contentVisible && cfg.enabled"
class="hb-pc-tools" class="hb-pc-tools"
@@ -136,7 +136,13 @@
<i class="fa-solid fa-download"></i> <i class="fa-solid fa-download"></i>
{{ savingImg ? '生成中…' : '保存海报' }} {{ savingImg ? '生成中…' : '保存海报' }}
</button> </button>
<FestiveQrCard :url="pageUrl" :size="112" :center-img="cfg.qrCenterImg" compact /> <FestiveQrCard
v-if="showCaptureQr"
:url="qrTargetUrl"
:size="112"
:center-img="cfg.qrCenterImg"
compact
/>
</aside> </aside>
<button <button
@@ -180,7 +186,19 @@ const enterLeaving = ref(false)
const contentVisible = ref(false) const contentVisible = ref(false)
const savingImg = ref(false) const savingImg = ref(false)
const isPc = ref(false) const isPc = ref(false)
const pageUrl = computed(() => (typeof window !== 'undefined' ? window.location.href : '')) /** 仅 ?qr=1 时:侧栏展示二维码,且保存海报打进图里 */
const showCaptureQr = computed(() => String(route.query.qr ?? '') === '1')
/** 二维码指向干净海报地址(去掉 qr 参数,避免扫开又带导出模式) */
const qrTargetUrl = computed(() => {
if (typeof window === 'undefined') return ''
try {
const u = new URL(window.location.href)
u.searchParams.delete('qr')
return u.toString()
} catch {
return window.location.href
}
})
const audio = useAudio() const audio = useAudio()
const hasMusic = computed(() => !!String(cfg.musicUrl || '').trim()) const hasMusic = computed(() => !!String(cfg.musicUrl || '').trim())
/** 与请柬一致:有音乐且自动播放成功后延迟自动揭幕 */ /** 与请柬一致:有音乐且自动播放成功后延迟自动揭幕 */
@@ -391,7 +409,7 @@ async function mountCaptureQr(host) {
target.appendChild(badge) target.appendChild(badge)
const qrCanvas = badge.querySelector('canvas') const qrCanvas = badge.querySelector('canvas')
await renderFestiveQr(qrCanvas, pageUrl.value, { await renderFestiveQr(qrCanvas, qrTargetUrl.value, {
size: renderSize, size: renderSize,
centerImg: cfg.qrCenterImg, centerImg: cfg.qrCenterImg,
}) })
@@ -432,7 +450,9 @@ async function savePosterImage() {
let cleanupBake = null let cleanupBake = null
try { try {
cleanupBake = await bakeObjectFitImages(el) cleanupBake = await bakeObjectFitImages(el)
cleanupQr = await mountCaptureQr(el) if (showCaptureQr.value) {
cleanupQr = await mountCaptureQr(el)
}
// 导出前重绘囍印章位图,避免 html2canvas 对 CJK 文本错位 // 导出前重绘囍印章位图,避免 html2canvas 对 CJK 文本错位
try { await document.fonts?.ready } catch { /* ignore */ } try { await document.fonts?.ready } catch { /* ignore */ }
el.querySelectorAll('canvas.sp-xi-mid, canvas.hb-xi-circle').forEach((c) => { el.querySelectorAll('canvas.sp-xi-mid, canvas.hb-xi-circle').forEach((c) => {

View File

@@ -116,12 +116,12 @@
<div class="sp-couple" :class="{ 'is-show': stage >= 5 }"> <div class="sp-couple" :class="{ 'is-show': stage >= 5 }">
<div class="sp-person sp-person-l"> <div class="sp-person sp-person-l">
<span class="sp-role calligraphy">新郎</span> <span class="sp-role calligraphy">{{ roleLeft }}</span>
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" /> <BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" />
</div> </div>
<canvas ref="xiSealRef" class="sp-xi-mid" width="56" height="56" aria-hidden="true"></canvas> <canvas ref="xiSealRef" class="sp-xi-mid" width="56" height="56" aria-hidden="true"></canvas>
<div class="sp-person sp-person-r"> <div class="sp-person sp-person-r">
<span class="sp-role calligraphy">新娘</span> <span class="sp-role calligraphy">{{ roleRight }}</span>
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" @animation-complete="bumpStage(6)" /> <BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" @animation-complete="bumpStage(6)" />
</div> </div>
</div> </div>
@@ -190,8 +190,16 @@ const bodyLines = computed(() => {
] ]
}) })
const groom = computed(() => props.cfg.groomName || props.cfg.sonName || '') const groom = computed(() => props.cfg.sonName || props.cfg.groomName || '')
const bride = computed(() => props.cfg.brideName || props.cfg.daughterInLawName || '') const bride = computed(() => props.cfg.daughterInLawName || props.cfg.brideName || '')
const roleLeft = computed(() => {
const s = String(props.cfg.sonLabel || '').replace(/[:]\s*$/, '').trim()
return s || '新郎'
})
const roleRight = computed(() => {
const s = String(props.cfg.daughterInLawLabel || '').replace(/[:]\s*$/, '').trim()
return s || '新娘'
})
async function paintXiSeal() { async function paintXiSeal() {
try { await document.fonts?.ready } catch { /* ignore */ } try { await document.fonts?.ready } catch { /* ignore */ }