1. 修复海报图片

This commit is contained in:
李琦
2026-08-03 16:54:14 +08:00
parent 18d7296f01
commit adc40c1834
5 changed files with 135 additions and 38 deletions

View File

@@ -66,12 +66,18 @@ export function heroFocusGridPoints(size = HERO_FOCUS_GRID) {
/**
* object-position CSS
* - heroFocusCss(cfg) 读 cfg.heroFocusX/Y
* - heroFocusCss(cfg) 读 PC 焦点 heroFocusX/Y
* - heroFocusCss(cfg, 'mobile') 读手机焦点(缺省回退 PC
* - heroFocusCss(x, y) 直接传百分比
* - heroFocusCss('center') 兼容旧字符串
*/
export function heroFocusCss(a, b) {
if (a && typeof a === 'object') {
if (b === 'mobile') {
const x = Number.isFinite(Number(a.heroFocusMobileX)) ? a.heroFocusMobileX : a.heroFocusX
const y = Number.isFinite(Number(a.heroFocusMobileY)) ? a.heroFocusMobileY : a.heroFocusY
return `${clampHeroFocus(x, 50)}% ${clampHeroFocus(y, 50)}%`
}
return `${clampHeroFocus(a.heroFocusX, 50)}% ${clampHeroFocus(a.heroFocusY, 50)}%`
}
if (typeof a === 'string' && LEGACY_HERO_FOCUS[a]) {
@@ -83,14 +89,27 @@ export function heroFocusCss(a, b) {
export function resolveHeroFocusXY(loaded = {}) {
const hasXY = Number.isFinite(Number(loaded.heroFocusX)) && Number.isFinite(Number(loaded.heroFocusY))
if (hasXY) {
return {
const pc = hasXY
? {
heroFocusX: clampHeroFocus(loaded.heroFocusX),
heroFocusY: clampHeroFocus(loaded.heroFocusY),
}
}
: (() => {
const legacy = LEGACY_HERO_FOCUS[loaded.heroFocus] || LEGACY_HERO_FOCUS.center
return { heroFocusX: legacy[0], heroFocusY: legacy[1] }
})()
const hasMobile = Number.isFinite(Number(loaded.heroFocusMobileX))
&& Number.isFinite(Number(loaded.heroFocusMobileY))
return {
...pc,
heroFocusMobileX: hasMobile
? clampHeroFocus(loaded.heroFocusMobileX)
: pc.heroFocusX,
heroFocusMobileY: hasMobile
? clampHeroFocus(loaded.heroFocusMobileY)
: pc.heroFocusY,
}
}
/** 解析为 1 | 2 | 3无法识别返回 0 */
@@ -147,9 +166,12 @@ function baseFields(side) {
mobileLayout: 'auto',
enabled: true,
heroImg: '',
/** 主图焦点百分比 0100object-position */
/** 主图焦点百分比 0100object-position— PC */
heroFocusX: 50,
heroFocusY: 50,
/** 主图焦点 — 手机端(缺省与 PC 相同) */
heroFocusMobileX: 50,
heroFocusMobileY: 50,
/** 二维码中心图;空则显示圆圈大囍 */
qrCenterImg: '',
title: '婚礼邀请函',
@@ -370,7 +392,7 @@ function pickSideSource(loaded, side) {
const SHARED_COPY_KEYS = [
'sonLabel', 'sonName', 'daughterInLawLabel', 'daughterInLawName',
'groomName', 'brideName', 'dateText', 'lunarText', 'venueText',
'lines', 'heroImg', 'heroFocusX', 'heroFocusY', 'qrCenterImg',
'lines', 'heroImg', 'heroFocusX', 'heroFocusY', 'heroFocusMobileX', 'heroFocusMobileY', 'qrCenterImg',
'musicUrl', 'invitePath', 'inviteButtonText', 'showInviteButton',
'enabled', 'pageTitle', 'pageDescription', 'footer', 'title',
'greeting', 'verticalSlogan', 'subSlogan1', 'subSlogan2', 'loveFooter',
@@ -454,7 +476,6 @@ export function normalizeSideLayouts(raw, side = 1) {
|| (src[2] && typeof src[2] === 'object' ? src[2] : null)
|| {}
const l1 = normalizePosterConfig(l1raw, s, 1)
l1.mobileLayout = 'portrait'
l1.layout = 1
let l2
if (Object.keys(l2raw).length) {
@@ -463,18 +484,15 @@ export function normalizeSideLayouts(raw, side = 1) {
l2 = defaultPosterForLayout(s, 2)
copySharedFields(l1, l2)
}
l2.mobileLayout = 'landscape'
l2.layout = 2
return { '1': l1, '2': l2 }
}
const flat = isFlatPosterConfig(src) ? src : {}
const l1 = normalizePosterConfig(flat, s, 1)
l1.mobileLayout = 'portrait'
l1.layout = 1
const l2 = defaultPosterForLayout(s, 2)
copySharedFields(l1, l2)
l2.mobileLayout = 'landscape'
l2.layout = 2
return { '1': l1, '2': l2 }
}

View File

@@ -762,22 +762,26 @@
/>
</a-form-item>
<a-form-item label="照片展示焦点">
<a-radio-group v-model:value="heroFocusDevice" size="small" class="mb-2" button-style="solid">
<a-radio-button value="pc">PC </a-radio-button>
<a-radio-button value="mobile">手机端</a-radio-button>
</a-radio-group>
<div
v-if="posterCfg.heroImg"
ref="heroFocusPreviewEl"
class="hero-focus-preview hero-focus-preview--drag"
:class="{ dragging: heroFocusDragging }"
:class="{ dragging: heroFocusDragging, 'is-mobile': heroFocusDevice === 'mobile' }"
@pointerdown="onHeroFocusPointerDown"
>
<img
:src="posterCfg.heroImg"
alt=""
draggable="false"
:style="{ objectPosition: heroFocusCss(posterCfg) }"
:style="{ objectPosition: heroFocusPreviewCss }"
/>
<span
class="hero-focus-crosshair"
:style="{ left: posterCfg.heroFocusX + '%', top: posterCfg.heroFocusY + '%' }"
:style="{ left: heroFocusActiveX + '%', top: heroFocusActiveY + '%' }"
/>
</div>
<div v-else class="hero-focus-preview hero-focus-preview--empty">
@@ -797,7 +801,8 @@
</button>
</div>
<div class="text-[11px] text-[#8A8680] mt-1">
在预览上拖拽十字准星或点下方 9×9 网格当前 {{ Math.round(posterCfg.heroFocusX) }}% · {{ Math.round(posterCfg.heroFocusY) }}%
{{ heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' }}焦点拖拽十字准星或点网格当前
{{ Math.round(heroFocusActiveX) }}% · {{ Math.round(heroFocusActiveY) }}%
</div>
</a-form-item>
<a-form-item label="二维码中心图">
@@ -1635,19 +1640,45 @@ function ensurePosterSlot(side = posterSideTab.value, layout = posterLayoutTab.v
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
const heroFocusDragging = ref(false)
const heroFocusDevice = ref('pc') // pc | mobile
const heroFocusActiveX = computed(() => {
const cfg = posterCfg.value
if (!cfg) return 50
if (heroFocusDevice.value === 'mobile') {
return Number.isFinite(Number(cfg.heroFocusMobileX)) ? cfg.heroFocusMobileX : (cfg.heroFocusX ?? 50)
}
return cfg.heroFocusX ?? 50
})
const heroFocusActiveY = computed(() => {
const cfg = posterCfg.value
if (!cfg) return 50
if (heroFocusDevice.value === 'mobile') {
return Number.isFinite(Number(cfg.heroFocusMobileY)) ? cfg.heroFocusMobileY : (cfg.heroFocusY ?? 50)
}
return cfg.heroFocusY ?? 50
})
const heroFocusPreviewCss = computed(() => {
const cfg = posterCfg.value
if (!cfg) return '50% 50%'
return heroFocusCss(cfg, heroFocusDevice.value === 'mobile' ? 'mobile' : undefined)
})
function setHeroFocus(x, y) {
const cfg = posterCfg.value
if (!cfg) return
if (heroFocusDevice.value === 'mobile') {
cfg.heroFocusMobileX = clampHeroFocus(x)
cfg.heroFocusMobileY = clampHeroFocus(y)
} else {
cfg.heroFocusX = clampHeroFocus(x)
cfg.heroFocusY = clampHeroFocus(y)
}
}
function isHeroFocusGridActive(pt) {
const cfg = posterCfg.value
if (!cfg) return false
return Math.abs(cfg.heroFocusX - pt.x) < 0.6
&& Math.abs(cfg.heroFocusY - pt.y) < 0.6
return Math.abs(heroFocusActiveX.value - pt.x) < 0.6
&& Math.abs(heroFocusActiveY.value - pt.y) < 0.6
}
function heroFocusFromClient(clientX, clientY) {
@@ -1733,7 +1764,6 @@ watch([posterSideTab, posterLayoutTab], () => {
const slot = ensurePosterSlot()
const l = layoutKey(posterLayoutTab.value)
slot.layout = Number(l)
slot.mobileLayout = l === '2' ? 'landscape' : 'portrait'
})
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
@@ -2244,11 +2274,9 @@ async function loadPosterConfig() {
async function onSavePosterConfig() {
posterSaving.value = true
try {
// 保存前强制各布局语义
// 仅同步 layout 编号与 Tab 一致,不覆盖用户所选 template / mobileLayout
for (const s of ['1', '2', '3']) {
ensurePosterSlot(s, '1').mobileLayout = 'portrait'
ensurePosterSlot(s, '1').layout = 1
ensurePosterSlot(s, '2').mobileLayout = 'landscape'
ensurePosterSlot(s, '2').layout = 2
}
const payload = normalizePosterBundle(posterBundle)
@@ -3452,6 +3480,10 @@ onUnmounted(() => {
user-select: none;
touch-action: none;
}
.hero-focus-preview.is-mobile {
width: min(100%, 200px);
aspect-ratio: 3 / 4;
}
.hero-focus-preview--drag { cursor: crosshair; }
.hero-focus-preview--drag.dragging { cursor: grabbing; }
.hero-focus-preview--empty {

View File

@@ -505,13 +505,10 @@ onMounted(async () => {
try {
const res = await getPosterConfig(side, layout)
const next = normalizePosterConfig(res.data || {}, side, layout)
// 强制布局语义竖→portrait横→landscape
next.layout = layout
next.mobileLayout = layout === 2 ? 'landscape' : 'portrait'
Object.assign(cfg, next)
} catch {
const next = normalizePosterConfig(null, side, layout)
next.mobileLayout = layout === 2 ? 'landscape' : 'portrait'
Object.assign(cfg, next)
}
applyPageMeta()

View File

@@ -159,7 +159,12 @@ const namesRightReady = ref(false)
const xiSealRef = ref(null)
let timers = []
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
const isNarrow = ref(typeof window !== 'undefined' && window.matchMedia('(max-width: 719px)').matches)
let narrowMq = null
function onNarrowChange(e) {
isNarrow.value = !!e.matches
}
const heroObjectPosition = computed(() => heroFocusCss(props.cfg, isNarrow.value ? 'mobile' : undefined))
const nameLeft = computed(() => `${props.cfg.sonLabel || ''}${props.cfg.sonName || ''}`)
const nameRight = computed(() => `${props.cfg.daughterInLawLabel || ''}${props.cfg.daughterInLawName || ''}`)
@@ -211,10 +216,15 @@ watch(
onMounted(() => {
nextTick(paintXiSeal)
window.addEventListener('resize', paintXiSeal, { passive: true })
narrowMq = window.matchMedia('(max-width: 719px)')
isNarrow.value = !!narrowMq.matches
narrowMq.addEventListener?.('change', onNarrowChange)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', paintXiSeal)
narrowMq?.removeEventListener?.('change', onNarrowChange)
narrowMq = null
timers.forEach(clearTimeout)
timers = []
})

View File

@@ -135,7 +135,12 @@ const lineStage = ref(0)
const xiSealRef = ref(null)
let timers = []
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
const isNarrow = ref(typeof window !== 'undefined' && window.matchMedia('(max-width: 719px)').matches)
let narrowMq = null
function onNarrowChange(e) {
isNarrow.value = !!e.matches
}
const heroObjectPosition = computed(() => heroFocusCss(props.cfg, isNarrow.value ? 'mobile' : undefined))
const bodyLines = computed(() => {
const lines = Array.isArray(props.cfg.lines) ? props.cfg.lines.filter(Boolean) : []
@@ -196,10 +201,15 @@ watch(
onMounted(() => {
nextTick(paintXiSeal)
window.addEventListener('resize', paintXiSeal, { passive: true })
narrowMq = window.matchMedia('(max-width: 719px)')
isNarrow.value = !!narrowMq.matches
narrowMq.addEventListener?.('change', onNarrowChange)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', paintXiSeal)
narrowMq?.removeEventListener?.('change', onNarrowChange)
narrowMq = null
timers.forEach(clearTimeout)
timers = []
})
@@ -242,20 +252,16 @@ onBeforeUnmount(() => {
flex-direction: row;
min-height: min(420px, 70dvh);
}
.sp-m-landscape .sp-left { width: 44%; min-height: 0; }
.sp-m-landscape .sp-right { width: 56%; padding: 12px 10px; }
.sp-m-landscape .sp-left { width: 54%; min-height: 0; }
.sp-m-landscape .sp-right { width: 46%; padding: 10px 8px; }
.sp-m-landscape .sp-slogan { font-size: clamp(22px, 6vw, 32px); }
.sp-m-landscape .sp-xi-top { font-size: 32px; }
.sp-m-landscape .sp-name { font-size: 14px; }
.sp-m-landscape .sp-line { font-size: 11px; }
.sp-m-landscape .sp-greeting { font-size: 12px; }
.sp-m-landscape .sp-photo { min-height: 100%; }
}
@media (min-width: 720px) {
.sp-card { flex-direction: row; min-height: min(560px, calc(100dvh - 40px)); }
.sp-left { width: 46%; }
.sp-right { width: 54%; }
.sp-left { width: 54%; }
.sp-right { width: 46%; }
}
.sp-left {
@@ -302,6 +308,16 @@ onBeforeUnmount(() => {
}
.sp-slogan-block.is-show { opacity: 1; }
/* 须写在基础规则之后,否则 top/left/bottom 会被后面的基础样式盖掉 */
@media (max-width: 719px) {
.sp-slogan-block {
left: auto;
right: 12%;
top: 46%;
bottom: auto;
}
}
.sp-slogan {
color: #f3e6c8 !important;
font-size: clamp(36px, 8vw, 52px);
@@ -424,4 +440,28 @@ onBeforeUnmount(() => {
color: var(--hb-gold-soft); padding: 7px 18px; border-radius: 999px;
letter-spacing: 0.28em; font-size: 12px; cursor: pointer;
}
/* 移动端右侧文案缩小(须在基础字号之后,避免被覆盖) */
@media (max-width: 719px) {
.sp-right { padding: 14px 12px 12px; }
.sp-wm { font-size: 120px; }
.sp-xi-top { font-size: 34px; margin-bottom: 4px; }
.sp-greeting { font-size: 11px; letter-spacing: 0.14em; margin-bottom: 8px; }
.sp-body { gap: 5px; min-height: 56px; margin-bottom: 10px; }
.sp-line { font-size: 11px; letter-spacing: 0.08em; line-height: 1.45; }
.sp-couple { margin: 4px 0 10px; }
.sp-person-l { padding-right: 16px; }
.sp-person-r { padding-left: 16px; }
.sp-role { font-size: 9px; letter-spacing: 0.2em; }
.sp-name { font-size: 14px; letter-spacing: 0.14em; }
.sp-xi-mid { width: 24px; height: 24px; margin-left: -12px; margin-top: -12px; }
.sp-meta-title { font-size: 10px; letter-spacing: 0.22em; margin-bottom: 2px; }
.sp-meta-title.mt { margin-top: 8px; }
.sp-meta-val { font-size: 12px; letter-spacing: 0.08em; }
.sp-meta-sub { font-size: 10px; }
.sp-love-text { font-size: 16px; }
.sp-heart { font-size: 12px; }
.sp-cta { margin-top: 8px; }
.sp-cta-btn { padding: 6px 14px; font-size: 11px; letter-spacing: 0.2em; }
}
</style>