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" /> +