1. 修复海报图片
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/** 海报 side:1=男方(/hb-n),2=女方(/hb-nv),3=回门宴(/hb-hm) */
|
/** 海报 side:1=男方(/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号楼 二楼',
|
||||||
'李琦 & 李逸烁 敬邀',
|
'李琦 & 李逸烁 敬邀',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -653,14 +653,14 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 海报(独立 poster_configs:1 男方 /hb-n,2 女方 /hb-nv,3 回门宴 /hb-hm) -->
|
<!-- 海报(独立 poster_configs:1 男方 /hb-n,2 女方 /hb-nv,3 新人模板 /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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user