/** 海报 side:1=男方(/hb-n),2=女方(/hb-nv),3=回门宴(/hb-hm) */ export const POSTER_SIDES = [ { key: 1, keyStr: '1', route: '/hb-n', label: '男方', short: 'n' }, { key: 2, keyStr: '2', route: '/hb-nv', label: '女方', short: 'nv' }, { key: 3, keyStr: '3', route: '/hb-hm', label: '回门宴', short: 'hm' }, ] /** 布局:1=竖版,2=横版(URL 后缀 /1 /2) */ export const POSTER_LAYOUTS = [ { value: '1', label: '竖版', desc: '经典竖向 / portrait', mobileLayout: 'portrait', template: 'classic' }, { value: '2', label: '横版', desc: '左图右文 / landscape', mobileLayout: 'landscape', template: 'split' }, ] export const POSTER_TEMPLATES = [ { value: 'classic', label: '经典竖版', desc: '顶图 + 正文逐行出现' }, { value: 'split', label: '左图右文', desc: '竖排书法 + 右侧宴席信息' }, ] /** 手机端布局:仅影响窄屏;PC 仍按模板本身版式 */ export const MOBILE_LAYOUTS = [ { value: 'auto', label: '自动布局', desc: '按屏幕宽度自动切换(当前默认)' }, { value: 'portrait', label: '竖向展示', desc: '手机端强制上下排布' }, { value: 'landscape', label: '横屏展示', desc: '手机端强制左右分栏' }, ] /** 后台 9×9 快速选点边长 */ export const HERO_FOCUS_GRID = 9 /** 旧版命名焦点 → 百分比(兼容已保存配置) */ const LEGACY_HERO_FOCUS = { 'top-left': [0, 0], top: [50, 0], 'top-right': [100, 0], left: [0, 50], center: [50, 50], right: [100, 50], 'bottom-left': [0, 100], bottom: [50, 100], 'bottom-right': [100, 100], } export function clampHeroFocus(n, fallback = 50) { const v = Number(n) if (!Number.isFinite(v)) return fallback return Math.min(100, Math.max(0, Math.round(v * 10) / 10)) } /** 生成 9×9 网格点 {x,y,key} */ export function heroFocusGridPoints(size = HERO_FOCUS_GRID) { const n = Math.max(2, size | 0) const pts = [] for (let row = 0; row < n; row += 1) { for (let col = 0; col < n; col += 1) { const x = (col / (n - 1)) * 100 const y = (row / (n - 1)) * 100 pts.push({ key: `${row}-${col}`, x: clampHeroFocus(x), y: clampHeroFocus(y), }) } } return pts } /** * object-position CSS * - heroFocusCss(cfg) 读 cfg.heroFocusX/Y * - heroFocusCss(x, y) 直接传百分比 * - heroFocusCss('center') 兼容旧字符串 */ export function heroFocusCss(a, b) { if (a && typeof a === 'object') { return `${clampHeroFocus(a.heroFocusX, 50)}% ${clampHeroFocus(a.heroFocusY, 50)}%` } if (typeof a === 'string' && LEGACY_HERO_FOCUS[a]) { const [x, y] = LEGACY_HERO_FOCUS[a] return `${x}% ${y}%` } return `${clampHeroFocus(a, 50)}% ${clampHeroFocus(b, 50)}%` } export function resolveHeroFocusXY(loaded = {}) { const hasXY = Number.isFinite(Number(loaded.heroFocusX)) && Number.isFinite(Number(loaded.heroFocusY)) if (hasXY) { return { 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] } } /** 解析为 1 | 2 | 3,无法识别返回 0 */ export function resolvePosterSide(input) { if (input === 1 || input === '1') return 1 if (input === 2 || input === '2') return 2 if (input === 3 || input === '3') return 3 const v = String(input || '').trim().toLowerCase() if (v === 'hm' || v === 'hmy' || v === 'return' || v === 'huimen') return 3 if (v === 'female' || v === 'nv' || v === 'f' || v === 'bride') return 2 if (v === 'male' || v === 'n' || v === 'm' || v === 'groom') return 1 return 0 } export function sideKey(side) { const s = resolvePosterSide(side) || 1 return String(s) } export function posterSideMeta(side) { const s = resolvePosterSide(side) || 1 return POSTER_SIDES.find((x) => x.key === s) || POSTER_SIDES[0] } /** 解析布局 1|2,无法识别默认 1(竖版) */ export function resolvePosterLayout(input) { if (input === 2 || input === '2') return 2 if (input === 1 || input === '1') return 1 const v = String(input || '').trim().toLowerCase() if (v === 'landscape' || v === 'h' || v === 'heng' || v === '横版') return 2 if (v === 'portrait' || v === 'v' || v === 'shu' || v === '竖版') return 1 return 1 } export function layoutKey(layout) { return String(resolvePosterLayout(layout)) } export function posterLayoutMeta(layout) { const k = layoutKey(layout) return POSTER_LAYOUTS.find((x) => x.value === k) || POSTER_LAYOUTS[0] } export function posterRoute(side, layout = 1) { const meta = posterSideMeta(side) return `${meta.route}/${layoutKey(layout)}` } function baseFields(side) { return { side: resolvePosterSide(side) || 1, layout: 1, template: 'classic', mobileLayout: 'auto', enabled: true, heroImg: '', /** 主图焦点百分比 0–100(object-position) */ heroFocusX: 50, heroFocusY: 50, /** 二维码中心图;空则显示圆圈大囍 */ qrCenterImg: '', title: '婚礼邀请函', /** 浏览器标签 / 分享用页面标题与描述(可与海报主标题不同) */ pageTitle: '', pageDescription: '', footer: '—诚挚邀请 ♥ 恭请光临—', loadingEffect: 'redSeal', enterEffect: 'fadeUp', showInviteButton: true, inviteButtonText: '打开请柬', invitePath: '/', /** 背景音乐 URL(从请柬 musicList 中选择;空=不播放) */ musicUrl: '', // split 模板字段 verticalSlogan: '吾家有喜', subSlogan1: '佳偶天成', subSlogan2: '百年好合', greeting: '尊敬的各位亲朋好友', groomName: '', brideName: '', dateText: '2026年10月3日 星期日', lunarText: '农历九月初八', venueText: '幸福大酒店二楼宴会厅', loveFooter: 'Love you', } } /** 男方(1) 默认 */ export function defaultSide1PosterConfig() { return { ...baseFields(1), side: 1, pageTitle: '💕李琦❤️李逸烁 · 男方婚礼邀请', pageDescription: '💍公历2026年9月4日(农历七月廿三)· 李琦 & 李逸烁 结婚典礼,诚邀您光临。', sonLabel: '儿子:', sonName: '李琦', daughterInLawLabel: '儿媳:', daughterInLawName: '李逸烁', groomName: '李琦', brideName: '李逸烁', verticalSlogan: '吾家有喜', dateText: '2026年9月4日 星期五', lunarText: '农历七月廿三', venueText: '山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼', lines: [ '各位亲朋好友', '吾家有喜 儿子结婚', '良辰已定 吉日待访', '兹定于2026年9月4日 星期五', '农历七月廿三 中午11:08分', '为儿子儿媳举办结婚典礼', '敬备喜宴 诚邀您携家人光临', '地址:山西省 阳泉市 芙蓉酒楼(阳泉店) 二楼', '李国贞 & 赵红燕 夫 妇 敬邀', ], } } /** 女方(2) 默认 */ export function defaultSide2PosterConfig() { return { ...baseFields(2), side: 2, template: 'split', mobileLayout: 'landscape', pageTitle: '💕李琦❤️李逸烁 · 女方婚礼邀请', pageDescription: '💍公历2026年9月4日(农历七月廿三)· 李逸烁 & 李琦 结婚典礼,诚邀您光临。', sonLabel: '女儿:', sonName: '李逸烁', daughterInLawLabel: '女婿:', daughterInLawName: '李琦', groomName: '李琦', brideName: '李逸烁', verticalSlogan: '小女出阁', dateText: '2026年9月4日 星期五', lunarText: '农历七月廿三', venueText: '山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼', lines: [ '各位亲朋好友', '吾家有喜 女儿出嫁', '良辰已定 吉日待访', '兹定于2026年9月4日 星期五', '农历七月廿三 中午11:08分', '为女儿女婿举办结婚典礼', '敬备喜宴 诚邀您携家人光临', '地址:山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼', '刘建国 & 王秀兰 夫 妇 敬邀', ], } } /** 回门宴(3) 默认:9月7日 */ export function defaultSide3PosterConfig() { return { ...baseFields(3), side: 3, template: 'classic', title: '回门宴邀请函', pageTitle: '💕李琦❤️李逸烁 · 回门宴邀请', pageDescription: '💍公历2026年9月7日(农历七月廿六)· 李琦 & 李逸烁 回门宴,诚邀您光临。', footer: '—回门宴请 ♥ 恭请光临—', sonLabel: '新郎:', sonName: '李琦', daughterInLawLabel: '新娘:', daughterInLawName: '李逸烁', groomName: '李琦', brideName: '李逸烁', verticalSlogan: '回门之喜', subSlogan1: '琴瑟和鸣', subSlogan2: '永结同心', dateText: '2026年9月7日 星期一', lunarText: '农历七月廿六', venueText: '山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼', enterEffect: 'curtain', loadingEffect: 'redSeal', lines: [ '各位亲朋好友', '新婚燕尔 回门宴请', '良辰已定 吉日待访', '兹定于2026年9月7日 星期一', '农历七月廿六 中午11:08分', '为新婚夫妇举办回门宴', '敬备薄酒 诚邀您携家人光临', '地址:山西省 阳泉市 芙蓉酒楼(阳泉店) 太行国际新城B区10号楼 二楼', '李琦 & 李逸烁 敬邀', ], } } export function defaultMalePosterConfig() { return defaultSide1PosterConfig() } export function defaultFemalePosterConfig() { return defaultSide2PosterConfig() } export function defaultPosterConfig() { return defaultSide1PosterConfig() } export function defaultPosterForSide(side) { const s = resolvePosterSide(side) || 1 if (s === 3) return defaultSide3PosterConfig() if (s === 2) return defaultSide2PosterConfig() return defaultSide1PosterConfig() } /** 按侧 + 布局生成默认配置(竖=classic/portrait,横=split/landscape) */ export function defaultPosterForLayout(side, layout = 1) { const s = resolvePosterSide(side) || 1 const l = resolvePosterLayout(layout) const base = defaultPosterForSide(s) const meta = posterLayoutMeta(l) return { ...base, side: s, layout: l, template: meta.template, mobileLayout: meta.mobileLayout, } } export function defaultSideLayouts(side) { return { '1': defaultPosterForLayout(side, 1), '2': defaultPosterForLayout(side, 2), } } export function defaultPosterBundle() { return { '1': defaultSideLayouts(1), '2': defaultSideLayouts(2), '3': defaultSideLayouts(3), } } export const LOADING_EFFECTS = [ { value: 'fade', label: '淡入加载' }, { value: 'redSeal', label: '红印囍字' }, { value: 'goldShimmer', label: '金字微光' }, ] export const ENTER_EFFECTS = [ { value: 'fadeUp', label: '上浮淡入' }, { value: 'scaleIn', label: '缩放进入' }, { value: 'curtain', label: '金幕拉开' }, ] function isLegacyFlatPoster(raw) { if (!raw || typeof raw !== 'object') return false if (raw['1'] || raw['2'] || raw['3'] || raw[1] || raw[2] || raw[3] || raw.male || raw.female) return false return 'title' in raw || 'lines' in raw || 'heroImg' in raw || 'sonName' in raw || 'template' in raw } /** 判断对象是否为「单份海报配置」(扁平字段),而非 {1,2} 布局包 */ function isFlatPosterConfig(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false if (obj['1'] || obj['2'] || obj[1] || obj[2]) { const hasNestedLayout = [obj['1'], obj['2'], obj[1], obj[2]].some( (v) => v && typeof v === 'object' && ('title' in v || 'template' in v || 'heroImg' in v || 'lines' in v), ) if (hasNestedLayout) return false } return 'title' in obj || 'lines' in obj || 'heroImg' in obj || 'sonName' in obj || 'template' in obj || 'groomName' in obj || 'mobileLayout' in obj || 'pageTitle' in obj } function pickSideSource(loaded, side) { const k = sideKey(side) if (loaded[k] && typeof loaded[k] === 'object') return loaded[k] if (side === 1 && loaded.male && typeof loaded.male === 'object') return loaded.male if (side === 2 && loaded.female && typeof loaded.female === 'object') return loaded.female if (side === 3 && loaded.hm && typeof loaded.hm === 'object') return loaded.hm return null } const SHARED_COPY_KEYS = [ 'sonLabel', 'sonName', 'daughterInLawLabel', 'daughterInLawName', 'groomName', 'brideName', 'dateText', 'lunarText', 'venueText', 'lines', 'heroImg', 'heroFocusX', 'heroFocusY', 'qrCenterImg', 'musicUrl', 'invitePath', 'inviteButtonText', 'showInviteButton', 'enabled', 'pageTitle', 'pageDescription', 'footer', 'title', 'greeting', 'verticalSlogan', 'subSlogan1', 'subSlogan2', 'loveFooter', 'loadingEffect', 'enterEffect', ] function copySharedFields(from, into) { if (!from || !into) return into for (const k of SHARED_COPY_KEYS) { if (from[k] !== undefined && from[k] !== null && from[k] !== '') { into[k] = Array.isArray(from[k]) ? from[k].slice() : from[k] } } return into } export function normalizePosterConfig(raw, side = 1, layout = 1) { const s = resolvePosterSide(side) || 1 const l = resolvePosterLayout(layout) const base = defaultPosterForLayout(s, l) const loaded = raw && typeof raw === 'object' ? raw : {} const lines = Array.isArray(loaded.lines) ? loaded.lines.map((x) => String(x ?? '')) : base.lines const template = POSTER_TEMPLATES.some((t) => t.value === loaded.template) ? loaded.template : base.template const mobileLayout = MOBILE_LAYOUTS.some((t) => t.value === loaded.mobileLayout) ? loaded.mobileLayout : base.mobileLayout const groomName = loaded.groomName || loaded.sonName || base.groomName const brideName = loaded.brideName || loaded.daughterInLawName || base.brideName return { ...base, ...loaded, side: s, layout: l, template, mobileLayout, lines: lines.length ? lines : base.lines, groomName, brideName, enabled: loaded.enabled !== false, showInviteButton: loaded.showInviteButton !== false, loadingEffect: LOADING_EFFECTS.some((e) => e.value === loaded.loadingEffect) ? loaded.loadingEffect : base.loadingEffect, enterEffect: ENTER_EFFECTS.some((e) => e.value === loaded.enterEffect) ? loaded.enterEffect : base.enterEffect, invitePath: loaded.invitePath || '/', inviteButtonText: loaded.inviteButtonText || base.inviteButtonText, musicUrl: typeof loaded.musicUrl === 'string' ? loaded.musicUrl.trim() : '', pageTitle: loaded.pageTitle || base.pageTitle || '', pageDescription: loaded.pageDescription || base.pageDescription || '', qrCenterImg: loaded.qrCenterImg || '', ...resolveHeroFocusXY(loaded), verticalSlogan: loaded.verticalSlogan || base.verticalSlogan, subSlogan1: loaded.subSlogan1 || base.subSlogan1, subSlogan2: loaded.subSlogan2 || base.subSlogan2, greeting: loaded.greeting || base.greeting, dateText: loaded.dateText || base.dateText, lunarText: loaded.lunarText || base.lunarText, venueText: loaded.venueText || base.venueText, loveFooter: loaded.loveFooter || base.loveFooter, } } /** 将单侧数据归一为 { "1": 竖版, "2": 横版 } */ export function normalizeSideLayouts(raw, side = 1) { const s = resolvePosterSide(side) || 1 const src = raw && typeof raw === 'object' ? raw : {} if (!isFlatPosterConfig(src) && (src['1'] || src['2'] || src[1] || src[2])) { const l1raw = (src['1'] && typeof src['1'] === 'object' ? src['1'] : null) || (src[1] && typeof src[1] === 'object' ? src[1] : null) || {} const l2raw = (src['2'] && typeof src['2'] === 'object' ? src['2'] : null) || (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) { l2 = normalizePosterConfig(l2raw, s, 2) } else { 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 } } /** 归一化为 { 1|2|3: { 1|2: cfg } };兼容旧 male/female、扁平与单侧扁平 */ export function normalizePosterBundle(raw) { const loaded = raw && typeof raw === 'object' ? raw : {} if (isLegacyFlatPoster(loaded)) { return { '1': normalizeSideLayouts(loaded, 1), '2': defaultSideLayouts(2), '3': defaultSideLayouts(3), } } return { '1': normalizeSideLayouts(pickSideSource(loaded, 1) || {}, 1), '2': normalizeSideLayouts(pickSideSource(loaded, 2) || {}, 2), '3': normalizeSideLayouts(pickSideSource(loaded, 3) || {}, 3), } } export function pickPosterConfig(bundle, side, layout = 1) { const normalized = normalizePosterBundle(bundle) const s = sideKey(side) const l = layoutKey(layout) return normalized[s]?.[l] || defaultPosterForLayout(s, l) } /** @deprecated 兼容旧调用,默认取竖版 */ export function pickPosterSide(bundle, side) { return pickPosterConfig(bundle, side, 1) }