diff --git a/vite-tailwindcss/index.html b/vite-tailwindcss/index.html index 9e50784..66bd934 100644 --- a/vite-tailwindcss/index.html +++ b/vite-tailwindcss/index.html @@ -3,7 +3,7 @@ - + diff --git a/vite-tailwindcss/src/components/GiftEffect.vue b/vite-tailwindcss/src/components/GiftEffect.vue index c57c135..bd67e16 100644 --- a/vite-tailwindcss/src/components/GiftEffect.vue +++ b/vite-tailwindcss/src/components/GiftEffect.vue @@ -1155,6 +1155,96 @@ function animate(timestamp) { } } + // 背景装饰先画:爱心 / 轨道光点(不压字) + if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) { + const outlineScale = heartOutlineScale(mt, now) + const outlineOpacity = heartOutlineOpacity(mt, now) * (ENABLE_NEON_GLOW ? 0.75 : 0.65) + const outlineSize = fontSize * 1.75 * outlineScale + ctx.save() + ctx.translate(cx, cy) + if (ENABLE_NEON_GLOW) { + const neonLayers = [ + { widthRatio: 0.038, alpha: 0.16, blur: 14 * SHADOW_MUL }, + { widthRatio: 0.016, alpha: 0.4, blur: 7 * SHADOW_MUL }, + { widthRatio: 0.005, alpha: 0.7, blur: 2 * SHADOW_MUL }, + ] + neonLayers.forEach((layer, idx) => { + ctx.globalAlpha = outlineOpacity * layer.alpha + ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor + ctx.lineWidth = Math.max(1, outlineSize * layer.widthRatio) + ctx.shadowColor = mt.glowColor + ctx.shadowBlur = layer.blur + drawHeartPath(ctx, 0, 0, outlineSize) + ctx.stroke() + }) + } else { + ctx.globalAlpha = outlineOpacity + ctx.strokeStyle = mt.glowColor + ctx.lineWidth = Math.max(1, outlineSize * 0.022) + ctx.shadowColor = mt.glowColor + ctx.shadowBlur = (isIOS ? 0 : outlineSize * 0.16) * SHADOW_MUL + drawHeartPath(ctx, 0, 0, outlineSize) + ctx.stroke() + } + ctx.restore() + } + + if (mt.showInterlockHearts && opacity > 0.12) { + const p = textProgress(mt, now) + const grow = easeOutCubic(Math.min(1, p / 0.28)) + const fade = p > 0.78 ? Math.max(0, 1 - (p - 0.78) / 0.22) : 1 + const breath = Math.sin((now - mt.birthTime) / 900) * 0.04 + const hs = fontSize * (1.35 + 0.22 * grow) * scale + const ox = fontSize * 0.58 * scale + ctx.save() + ctx.translate(cx, cy) + const drawInterlockHeart = (offsetX, rotateAngle) => { + ctx.save() + ctx.translate(offsetX, 0) + ctx.rotate(rotateAngle) + ctx.globalAlpha = opacity * 0.28 * fade + ctx.strokeStyle = mt.glowColor + ctx.lineWidth = Math.max(1.2, hs * 0.028) + ctx.shadowColor = mt.glowColor + ctx.shadowBlur = isIOS ? 0 : 8 * SHADOW_MUL + drawHeartPath(ctx, 0, 0, hs) + ctx.stroke() + ctx.globalAlpha = opacity * 0.16 * fade + ctx.lineWidth = Math.max(0.8, hs * 0.012) + ctx.shadowBlur = isIOS ? 0 : 3 * SHADOW_MUL + drawHeartPath(ctx, 0, 0, hs * 0.92) + ctx.stroke() + ctx.restore() + } + drawInterlockHeart(-ox, -0.18 + breath) + drawInterlockHeart(ox, 0.18 - breath) + ctx.restore() + } + + if (mt.orb && opacity > 0.18) { + const numOrbs = mt.showInterlockHearts + ? (isIOS ? 8 : 12) + : (mt.showHeartOutline ? 10 : 8) + const orbRadius = fontSize * (mt.showInterlockHearts ? 1.45 : 1.0 + 0.12 * glowIntensity) + const age = (now - mt.birthTime) / 1000 + for (let j = 0; j < numOrbs; j += 1) { + const angle = age * (mt.showInterlockHearts ? 0.85 : 0.7) + j * ((Math.PI * 2) / numOrbs) + const orbX = cx + Math.cos(angle) * orbRadius + const orbY = cy + Math.sin(angle) * orbRadius * 0.66 + const orbSize = fontSize * 0.028 * (1 + Math.sin(age * 1.6 + j) * 0.28) + ctx.save() + ctx.globalAlpha = opacity * (mt.showInterlockHearts ? 0.18 : 0.28 + Math.sin(age * 1.4 + j) * 0.14) + ctx.fillStyle = mt.glowColor + ctx.shadowColor = mt.glowColor + ctx.shadowBlur = isIOS ? 0 : orbSize * 4 + ctx.beginPath() + ctx.arc(orbX, orbY, Math.max(1.5, orbSize), 0, Math.PI * 2) + ctx.fill() + ctx.restore() + } + } + + // 书法文字(最上层) ctx.save() ctx.globalAlpha = opacity ctx.translate(cx, cy) @@ -1164,20 +1254,20 @@ function animate(timestamp) { const showMult = mt.countRevealed && mt.count > 1 const mainY = showMult ? -fontSize * 0.42 : 0 drawMilestoneLine( - ctx, - mt.baseText || mt.text, - fontSize, - glowBlur, - mt.gradient, - mt.strokeColor, - mt.glowColor, - mainY, - { - underStroke: mt.underStroke, - charGap: mt.charGap, - birthTime: mt.birthTime, - now, - }, + ctx, + mt.baseText || mt.text, + fontSize, + glowBlur, + mt.gradient, + mt.strokeColor, + mt.glowColor, + mainY, + { + underStroke: mt.underStroke, + charGap: mt.charGap, + birthTime: mt.birthTime, + now, + }, ) if (showMult) { const n = scrolledCount(mt, now) @@ -1189,143 +1279,24 @@ function animate(timestamp) { ctx.translate(0, multY) ctx.scale(pop, pop) drawMilestoneLine( - ctx, - `×${n}`, - multSize, - glowBlur * 0.7, - mt.gradient, - mt.strokeColor, - mt.glowColor, - 0, - { - underStroke: mt.underStroke, - charGap: 0.04, - birthTime: mt.countRevealAt || now, - now, - }, + ctx, + `×${n}`, + multSize, + glowBlur * 0.7, + mt.gradient, + mt.strokeColor, + mt.glowColor, + 0, + { + underStroke: mt.underStroke, + charGap: 0.04, + birthTime: mt.countRevealAt || now, + now, + }, ) ctx.restore() } ctx.restore() - - // 核心心形轮廓重构:将难看的单线条换成多层高质感的霓虹流光轮廓 - if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) { - const outlineScale = heartOutlineScale(mt, now) - const outlineOpacity = heartOutlineOpacity(mt, now) * (ENABLE_NEON_GLOW ? 1.0 : 0.7) - const outlineSize = fontSize * 1.55 * outlineScale - - ctx.save() - ctx.translate(cx, cy) - - if (ENABLE_NEON_GLOW) { - // [新增] 特效三:惊艳的霓虹流光多层绘制 - ctx.globalCompositeOperation = 'lighter' - - // 渲染 3 层不同的发光和线宽来模拟极具科技感和仙气的霓虹能量管 - const neonLayers = [ - { widthRatio: 0.045, alpha: 0.25, blur: 20 * SHADOW_MUL }, // 底层弥散光晕 - { widthRatio: 0.02, alpha: 0.6, blur: 10 * SHADOW_MUL }, // 中层亮色核心 - { widthRatio: 0.006, alpha: 1.0, blur: 2 * SHADOW_MUL } // 顶层高亮白心 - ] - - neonLayers.forEach((layer, idx) => { - ctx.globalAlpha = outlineOpacity * layer.alpha - // 顶层使用纯白,底层使用礼物的主题发光色 - ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor - ctx.lineWidth = Math.max(1, outlineSize * layer.widthRatio) - ctx.shadowColor = mt.glowColor - ctx.shadowBlur = layer.blur - drawHeartPath(ctx, 0, 0, outlineSize) - ctx.stroke() - }) - } else { - // 降级旧方案(普通单线条描边) - ctx.globalAlpha = outlineOpacity - ctx.strokeStyle = mt.glowColor - ctx.lineWidth = Math.max(1, outlineSize * 0.022) - ctx.shadowColor = mt.glowColor - ctx.shadowBlur = (isIOS ? 0 : outlineSize * 0.16) * SHADOW_MUL - drawHeartPath(ctx, 0, 0, outlineSize) - ctx.stroke() - } - - ctx.restore() - } - - // 永结同心(双心相扣)重构:同样支持霓虹发光材质 - if (mt.showInterlockHearts && opacity > 0.12) { - const p = textProgress(mt, now) - const grow = easeOutCubic(Math.min(1, p / 0.28)) - const fade = p > 0.78 ? Math.max(0, 1 - (p - 0.78) / 0.22) : 1 - const breath = Math.sin((now - mt.birthTime) / 900) * 0.04 - const hs = fontSize * (1.05 + 0.28 * grow) * scale - const ox = fontSize * 0.38 * scale - - ctx.save() - ctx.translate(cx, cy) - - const drawInterlockHeart = (offsetX, rotateAngle) => { - ctx.save() - ctx.translate(offsetX, 0) - ctx.rotate(rotateAngle) - - if (ENABLE_NEON_GLOW) { - ctx.globalCompositeOperation = 'lighter' - const neonLayers = [ - { widthRatio: 0.045, alpha: 0.2, blur: 15 * SHADOW_MUL }, - { widthRatio: 0.02, alpha: 0.5, blur: 8 * SHADOW_MUL }, - { widthRatio: 0.006, alpha: 0.9, blur: 2 * SHADOW_MUL } - ] - neonLayers.forEach((layer, idx) => { - ctx.globalAlpha = opacity * fade * layer.alpha - ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor - ctx.lineWidth = Math.max(1, hs * layer.widthRatio) - ctx.shadowColor = mt.glowColor - ctx.shadowBlur = layer.blur - drawHeartPath(ctx, 0, 0, hs) - ctx.stroke() - }) - } else { - ctx.globalAlpha = opacity * 0.38 * fade - ctx.strokeStyle = mt.glowColor - ctx.lineWidth = Math.max(1, hs * 0.022) - ctx.shadowColor = mt.glowColor - ctx.shadowBlur = isIOS ? 0 : 6 * SHADOW_MUL - drawHeartPath(ctx, 0, 0, hs) - ctx.stroke() - } - ctx.restore() - } - - // 绘制左心和右心 - drawInterlockHeart(-ox, -0.2 + breath) - drawInterlockHeart(ox, 0.2 - breath) - - ctx.restore() - } - - if (mt.orb && opacity > 0.18) { - const numOrbs = mt.showInterlockHearts - ? (isIOS ? 8 : 12) - : (mt.showHeartOutline ? 10 : 8) - const orbRadius = fontSize * (mt.showInterlockHearts ? 1.15 : 1.0 + 0.12 * glowIntensity) - const age = (now - mt.birthTime) / 1000 - for (let j = 0; j < numOrbs; j += 1) { - const angle = age * (mt.showInterlockHearts ? 0.85 : 0.7) + j * ((Math.PI * 2) / numOrbs) - const orbX = cx + Math.cos(angle) * orbRadius - const orbY = cy + Math.sin(angle) * orbRadius * 0.66 - const orbSize = fontSize * 0.028 * (1 + Math.sin(age * 1.6 + j) * 0.28) - ctx.save() - ctx.globalAlpha = opacity * (0.28 + Math.sin(age * 1.4 + j) * 0.14) - ctx.fillStyle = mt.glowColor - ctx.shadowColor = mt.glowColor - ctx.shadowBlur = isIOS ? 0 : orbSize * 4 - ctx.beginPath() - ctx.arc(orbX, orbY, Math.max(1.5, orbSize), 0, Math.PI * 2) - ctx.fill() - ctx.restore() - } - } } // 爆闪:中心高亮 + 全屏一层,保证冲击感 diff --git a/vite-tailwindcss/src/composables/posterDefaults.js b/vite-tailwindcss/src/composables/posterDefaults.js index 0e0a3e4..28955b0 100644 --- a/vite-tailwindcss/src/composables/posterDefaults.js +++ b/vite-tailwindcss/src/composables/posterDefaults.js @@ -18,6 +18,75 @@ export const MOBILE_LAYOUTS = [ { 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 @@ -47,6 +116,9 @@ function baseFields(side) { mobileLayout: 'auto', enabled: true, heroImg: '', + /** 主图焦点百分比 0–100(object-position) */ + heroFocusX: 50, + heroFocusY: 50, /** 二维码中心图;空则显示圆圈大囍 */ qrCenterImg: '', title: '婚礼邀请函', @@ -269,6 +341,7 @@ export function normalizePosterConfig(raw, side = 1) { 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, diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 5bd65c7..3633c3f 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -608,6 +608,45 @@ :uploading="uploading" /> + +
+ + +
+
+ 请先上传海报图片,再拖拽设置焦点 +
+
+ +
+
+ 在预览上拖拽十字准星,或点下方 9×9 网格;当前 {{ Math.round(posterCfg.heroFocusX) }}% · {{ Math.round(posterCfg.heroFocusY) }}% +
+
posterBundle[sideKey(posterSideTab.value)] || posterBundle['1']) + +const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID) +const heroFocusPreviewEl = ref(null) +const heroFocusDragging = ref(false) + +function setHeroFocus(x, y) { + const cfg = posterCfg.value + if (!cfg) return + 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 +} + +function heroFocusFromClient(clientX, clientY) { + const el = heroFocusPreviewEl.value + if (!el) return + const rect = el.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + const x = ((clientX - rect.left) / rect.width) * 100 + const y = ((clientY - rect.top) / rect.height) * 100 + setHeroFocus(x, y) +} + +function onHeroFocusPointerMove(e) { + if (!heroFocusDragging.value) return + heroFocusFromClient(e.clientX, e.clientY) +} + +function onHeroFocusPointerUp() { + if (!heroFocusDragging.value) return + heroFocusDragging.value = false + window.removeEventListener('pointermove', onHeroFocusPointerMove) + window.removeEventListener('pointerup', onHeroFocusPointerUp) + window.removeEventListener('pointercancel', onHeroFocusPointerUp) +} + +function onHeroFocusPointerDown(e) { + if (!posterCfg.value?.heroImg) return + e.preventDefault() + heroFocusDragging.value = true + heroFocusFromClient(e.clientX, e.clientY) + window.addEventListener('pointermove', onHeroFocusPointerMove) + window.addEventListener('pointerup', onHeroFocusPointerUp) + window.addEventListener('pointercancel', onHeroFocusPointerUp) +} + const posterMusicSelectOptions = computed(() => { const list = Array.isArray(posterMusicOptions.value) ? posterMusicOptions.value : [] const opts = list @@ -2048,6 +2140,7 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener('resize', onResize) + onHeroFocusPointerUp() disposeVisitCharts() }) @@ -2407,4 +2500,81 @@ onUnmounted(() => { width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); } + +.hero-focus-pad { + display: grid; + grid-template-columns: repeat(var(--hero-focus-n, 9), 22px); + gap: 4px; + width: fit-content; + padding: 8px; + border-radius: 10px; + background: #f7f4ef; + border: 1px solid #e7e3db; +} +.hero-focus-cell { + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid #ddd6cb; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + transition: border-color 0.15s, background 0.15s; +} +.hero-focus-cell:hover { border-color: #a88c6b; } +.hero-focus-cell.active { + border-color: #a88c6b; + background: rgba(168, 140, 107, 0.14); +} +.hero-focus-dot { + width: 5px; + height: 5px; + border-radius: 999px; + background: #c4b8a4; +} +.hero-focus-cell.active .hero-focus-dot { background: #a88c6b; } +.hero-focus-preview { + position: relative; + width: min(100%, 280px); + aspect-ratio: 16 / 11; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e7e3db; + background: #f0ebe3; + user-select: none; + touch-action: none; +} +.hero-focus-preview--drag { cursor: crosshair; } +.hero-focus-preview--drag.dragging { cursor: grabbing; } +.hero-focus-preview--empty { + display: flex; + align-items: center; + justify-content: center; + color: #8A8680; + font-size: 12px; + padding: 12px; + text-align: center; +} +.hero-focus-preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; +} +.hero-focus-crosshair { + position: absolute; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border: 2px solid #fff; + border-radius: 999px; + box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.85), 0 1px 4px rgba(0, 0, 0, 0.25); + background: rgba(168, 140, 107, 0.35); + pointer-events: none; + z-index: 1; +} diff --git a/vite-tailwindcss/src/views/hb/index.vue b/vite-tailwindcss/src/views/hb/index.vue index b7fa775..4c62b03 100644 --- a/vite-tailwindcss/src/views/hb/index.vue +++ b/vite-tailwindcss/src/views/hb/index.vue @@ -50,6 +50,18 @@
+ +
+
+
婚礼邀请函
+
+
轻触打开请柬
+
+
@@ -134,6 +146,10 @@ const isPc = ref(false) const pageUrl = computed(() => (typeof window !== 'undefined' ? window.location.href : '')) const audio = useAudio() const hasMusic = computed(() => !!String(cfg.musicUrl || '').trim()) +/** 与请柬一致:有音乐且自动播放成功后延迟自动揭幕 */ +const POSTER_AUTO_OPEN_MS = 3600 +let posterOpening = false +let autoOpenTimer = null let timers = [] function later(fn, ms) { @@ -256,9 +272,10 @@ async function savePosterImage() { } } +/** @returns {Promise} 无音乐视为成功;有音乐则返回是否自动播放成功 */ async function setupPosterMusic() { const url = String(cfg.musicUrl || '').trim() - if (!url) return + if (!url) return true let track = { url, name: '背景音乐' } try { const res = await getConfig() @@ -269,8 +286,34 @@ async function setupPosterMusic() { /* 仅用 poster 上的 url 亦可播放 */ } audio.setTracks([track], url) - const ok = await audio.attemptAutoplay() - if (!ok) audio.armAutoplay() + return audio.attemptAutoplay() +} + +/** 执行海报淡出揭幕动画(enter → content) */ +async function playPosterEnter() { + phase.value = 'enter' + contentVisible.value = false + enterLeaving.value = false + await nextTick() + + const enterMs = { fadeUp: 700, scaleIn: 800, curtain: 1000 }[cfg.enterEffect] || 700 + later(() => { + enterLeaving.value = true + contentVisible.value = true + }, 80) + await new Promise((r) => later(r, enterMs)) + phase.value = 'content' +} + +/** 打开海报:手势或自动;顺带在用户手势下再尝试播音乐 */ +async function openPoster() { + if (posterOpening) return + if (phase.value !== 'waiting' && phase.value !== 'loading') return + posterOpening = true + clearTimeout(autoOpenTimer) + autoOpenTimer = null + if (hasMusic.value) audio.play() + await playPosterEnter() } async function runSequence() { @@ -283,17 +326,23 @@ async function runSequence() { return } - phase.value = 'enter' - contentVisible.value = false - await nextTick() + const musicOk = await setupPosterMusic() - const enterMs = { fadeUp: 700, scaleIn: 800, curtain: 1000 }[cfg.enterEffect] || 700 - later(() => { - enterLeaving.value = true - contentVisible.value = true - }, 80) - await new Promise((r) => later(r, enterMs)) - phase.value = 'content' + if (hasMusic.value && !musicOk) { + // 与请柬一致:自动播放失败则停住,等待轻触后再揭幕 + phase.value = 'waiting' + return + } + + if (hasMusic.value && musicOk) { + // 有音乐且已播出:短暂停留后自动淡出揭幕 + autoOpenTimer = later(() => openPoster(), POSTER_AUTO_OPEN_MS) + phase.value = 'waiting' + return + } + + // 未配置音乐:直接揭幕 + await openPoster() } onMounted(async () => { @@ -307,13 +356,14 @@ onMounted(async () => { Object.assign(cfg, normalizePosterConfig(null, side)) } applyPageMeta() - setupPosterMusic() runSequence() }) onUnmounted(() => { timers.forEach(clearTimeout) timers = [] + clearTimeout(autoOpenTimer) + autoOpenTimer = null window.removeEventListener('resize', refreshPc) }) @@ -369,6 +419,38 @@ onUnmounted(() => { pointer-events: none; } .hb-pc-tools > * { pointer-events: auto; } +.hb-waiting { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 28px; + background: var(--hb-red-deep); + cursor: pointer; +} +.hb-open-hint { + display: inline-flex; + align-items: center; + gap: 8px; + color: #f0d78c; + font-size: 13px; + letter-spacing: 0.26em; + animation: hb-hint-pulse 1.6s ease-in-out infinite; +} +.hb-open-hint .dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: #f0d78c; + box-shadow: 0 0 8px rgba(240, 215, 140, 0.7); +} +@keyframes hb-hint-pulse { + 0%, 100% { opacity: 0.75; } + 50% { opacity: 1; } +} .hb-music-fab { position: fixed; left: 16px; diff --git a/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue b/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue index d8122c8..730bfc4 100644 --- a/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue +++ b/vite-tailwindcss/src/views/hb/templates/ClassicPoster.vue @@ -37,7 +37,13 @@
- +
婚礼照片
@@ -137,6 +143,7 @@ import { computed, ref, watch } from 'vue' import BlurText from '@/components/vue-bits/BlurText.vue' import TextType from '@/components/vue-bits/TextType.vue' +import { heroFocusCss } from '@/composables/posterDefaults' const props = defineProps({ cfg: { type: Object, required: true }, @@ -149,6 +156,7 @@ const lineStage = ref(0) const namesRightReady = ref(false) let timers = [] +const heroObjectPosition = computed(() => heroFocusCss(props.cfg)) const nameLeft = computed(() => `${props.cfg.sonLabel || ''}${props.cfg.sonName || ''}`) const nameRight = computed(() => `${props.cfg.daughterInLawLabel || ''}${props.cfg.daughterInLawName || ''}`) diff --git a/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue b/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue index 19b833b..c394c28 100644 --- a/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue +++ b/vite-tailwindcss/src/views/hb/templates/SplitPoster.vue @@ -9,7 +9,12 @@
- +
婚礼照片
@@ -115,6 +120,7 @@ import { computed, ref, watch } from 'vue' import BlurText from '@/components/vue-bits/BlurText.vue' import TextType from '@/components/vue-bits/TextType.vue' +import { heroFocusCss } from '@/composables/posterDefaults' const props = defineProps({ cfg: { type: Object, required: true }, @@ -126,6 +132,8 @@ const stage = ref(0) const lineStage = ref(0) let timers = [] +const heroObjectPosition = computed(() => heroFocusCss(props.cfg)) + const bodyLines = computed(() => { const lines = Array.isArray(props.cfg.lines) ? props.cfg.lines.filter(Boolean) : [] if (lines.length) return lines.slice(0, 4) diff --git a/vite-tailwindcss/src/views/index/index.vue b/vite-tailwindcss/src/views/index/index.vue index 1a203e4..03a7c25 100644 --- a/vite-tailwindcss/src/views/index/index.vue +++ b/vite-tailwindcss/src/views/index/index.vue @@ -121,7 +121,7 @@ :disabled="likeBusy" title="点赞(长按快捷次数)" @click="handleLikeClick" - @touchstart.passive="onLikePressStart" + @touchstart="onLikePressStart" @touchend="onLikePressEnd" @touchcancel="onLikePressCancel" @mousedown="onLikePressStart" @@ -957,6 +957,8 @@ const clearLikePressTimer = () => { const onLikePressStart = (e) => { if (likeBusy.value) return if (e.type === 'mousedown' && e.button !== 0) return + // 阻止 iOS 长按选字 / 呼出菜单;touch-action:manipulation 防连点放大 + if (e.type === 'touchstart' && e.cancelable) e.preventDefault() likePressFired = false clearLikePressTimer() likePressTimer = setTimeout(() => { @@ -970,16 +972,25 @@ const onLikePressStart = (e) => { }, 420) } -const onLikePressEnd = () => { +const onLikePressEnd = (e) => { + const longPress = likePressFired clearLikePressTimer() + // touchstart 已 preventDefault 时,部分机型不会再派发 click,短按在此补一次 + if (e?.type === 'touchend' && !longPress) { + likeSkipClick = true + handleLike(1) + return + } + if (longPress) likeSkipClick = true } const onLikePressCancel = () => { clearLikePressTimer() } -const handleLikeClick = () => { - if (likeSkipClick || likePressFired) { +const handleLikeClick = (e) => { + // 触摸已在 touchend 处理,忽略随后的合成 click + if (e?.pointerType === 'touch' || likeSkipClick || likePressFired) { likeSkipClick = false likePressFired = false return @@ -1714,7 +1725,24 @@ const applyGiftCounts = (counts, total) => { giftTotal.value = Number(total) || Object.values(next).reduce((a, b) => a + b, 0) } +let giftPanelCloseTimer = null +const GIFT_PANEL_IDLE_CLOSE_MS = 800 + +const clearGiftPanelCloseTimer = () => { + clearTimeout(giftPanelCloseTimer) + giftPanelCloseTimer = null +} + +const scheduleGiftPanelClose = () => { + clearGiftPanelCloseTimer() + giftPanelCloseTimer = setTimeout(() => { + giftPanelCloseTimer = null + closeGiftPanel() + }, GIFT_PANEL_IDLE_CLOSE_MS) +} + const openGiftPanel = async () => { + clearGiftPanelCloseTimer() showLikeBubbles.value = false syncGuestNameIntoForms() giftForm.name = getGuestName() || giftForm.name @@ -1744,14 +1772,21 @@ const onGiftPlayStart = ({ giftType, name, count, phase }) => { } const closeGiftPanel = () => { + clearGiftPanelCloseTimer() showGiftPanel.value = false } const handleSendGift = async (giftType) => { - if (giftBusy.value) return + // 点了任意礼物就延后关闭;忙碌中也续上 800ms,方便连送 + clearGiftPanelCloseTimer() + if (giftBusy.value) { + scheduleGiftPanelClose() + return + } const cost = giftCostOf(giftType) if (giftQuota.available < cost) { const need = cost - giftQuota.available + scheduleGiftPanelClose() return toast.error(`点赞不足,再点赞 ${need} 次可兑换`) } const name = giftForm.name.trim() @@ -1767,11 +1802,12 @@ const handleSendGift = async (giftType) => { syncGuestNameIntoForms() applyGiftCounts(res.data?.counts, res.data?.total) applyGiftQuota(res.data?.quota) - closeGiftPanel() if (showGiftFx.value) { giftEffectRef.value?.play({ giftType, name, count: 1 }) } toast.success('心意已送达') + // 800ms 内再点其他礼物则继续送;否则自动收起面板 + scheduleGiftPanelClose() } catch (e) { toast.error(e.message || '送礼失败') try { @@ -1933,6 +1969,7 @@ onUnmounted(() => { clearTimeout(likePressTimer) clearTimeout(likeBubblesHideTimer) clearTimeout(likePulseTimer) + clearGiftPanelCloseTimer() clearBottomReturn() clearBlessingAutoClose() }) @@ -2037,6 +2074,10 @@ watch( justify-content: center; z-index: 3; overflow: visible; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; } .like-bubbles { position: absolute; @@ -2095,6 +2136,12 @@ watch( color: #a88c6b; box-shadow: 0 6px 18px rgba(92, 74, 53, 0.08); transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease; + /* 防长按选字 / iOS 连点双击放大 */ + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; } .action-pill:active { transform: scale(0.97); } .action-pill-icon { width: 18px; height: 18px; display: block; }