diff --git a/vite-tailwindcss/src/composables/posterDefaults.js b/vite-tailwindcss/src/composables/posterDefaults.js index a7e8ea5..5ffdff3 100644 --- a/vite-tailwindcss/src/composables/posterDefaults.js +++ b/vite-tailwindcss/src/composables/posterDefaults.js @@ -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, } - const legacy = LEGACY_HERO_FOCUS[loaded.heroFocus] || LEGACY_HERO_FOCUS.center - return { heroFocusX: legacy[0], heroFocusY: legacy[1] } } /** 解析为 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 } } diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 1e697e4..932fda5 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -762,22 +762,26 @@ /> + + PC 端 + 手机端 +
@@ -797,7 +801,8 @@
- 在预览上拖拽十字准星,或点下方 9×9 网格;当前 {{ Math.round(posterCfg.heroFocusX) }}% · {{ Math.round(posterCfg.heroFocusY) }}% + {{ heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' }}焦点:拖拽十字准星或点网格;当前 + {{ Math.round(heroFocusActiveX) }}% · {{ Math.round(heroFocusActiveY) }}%
@@ -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 - cfg.heroFocusX = clampHeroFocus(x) - cfg.heroFocusY = clampHeroFocus(y) + 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 { diff --git a/vite-tailwindcss/src/views/hb/index.vue b/vite-tailwindcss/src/views/hb/index.vue index fc2be22..34e5b2f 100644 --- a/vite-tailwindcss/src/views/hb/index.vue +++ b/vite-tailwindcss/src/views/hb/index.vue @@ -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() diff --git a/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue b/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue index 8fcac4a..b3c0a37 100644 --- a/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue +++ b/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue @@ -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 = [] }) diff --git a/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue b/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue index 0e487a9..5074360 100644 --- a/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue +++ b/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue @@ -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; } +}