1. 修复海报图片
This commit is contained in:
@@ -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: '',
|
||||
/** 主图焦点百分比 0–100(object-position) */
|
||||
/** 主图焦点百分比 0–100(object-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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user