1. 礼物特效

This commit is contained in:
李琦
2026-08-03 13:36:16 +08:00
parent e9410c47a9
commit eac87759f3
4 changed files with 307 additions and 82 deletions

View File

@@ -1,61 +1,95 @@
<template>
<div class="min-h-screen bg-[#f6f4ef] text-[#2c2c2c]">
<!-- 顶部栏 -->
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15 px-4 sm:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="text-xl text-[#a88c6b] font-serif shrink-0"></span>
<div class="min-w-0">
<div class="text-sm tracking-[0.18em] font-light truncate">婚礼邀请函 · 内容编辑后台</div>
<div class="text-[10px] text-[#8A8680] tracking-[0.12em] mt-0.5">
制作者 · <span class="text-[#a88c6b]">年糕崽崽</span>
<header class="admin-header sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15">
<div class="admin-header-row">
<div class="flex items-center gap-3 min-w-0 shrink-0">
<span class="text-xl text-[#a88c6b] font-serif shrink-0"></span>
<div class="min-w-0">
<div class="text-sm tracking-[0.18em] font-light truncate">婚礼邀请函 · 内容编辑后台</div>
<div class="text-[10px] text-[#8A8680] tracking-[0.12em] mt-0.5">
制作者 · <span class="text-[#a88c6b]">年糕崽崽</span>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<a-segmented
v-model:value="menuLayout"
size="small"
class="admin-layout-seg"
:options="menuLayoutOptions"
/>
<a-button size="small" @click="openPreview">
<i class="fa-solid fa-eye mr-1 text-[#a88c6b]"></i>预览请柬
</a-button>
<a-dropdown>
<a-button size="small">
<i class="fa-solid fa-scroll mr-1 text-[#a88c6b]"></i>预览海报 <i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i>
<!-- 顶部菜单一级分类 + 下拉 -->
<nav v-if="menuLayout === 'top'" class="admin-top-menu" aria-label="后台菜单">
<a-dropdown
v-for="group in adminMenuGroups"
:key="group.key"
:trigger="['hover', 'click']"
placement="bottomLeft"
>
<button
type="button"
class="admin-top-menu-trigger"
:class="{ active: isMenuGroupActive(group) }"
>
<span>{{ group.label }}</span>
<i class="fa-solid fa-chevron-down admin-top-menu-caret" />
</button>
<template #overlay>
<a-menu
class="admin-top-dropdown"
:selected-keys="[activeTab]"
@click="({ key }) => { activeTab = String(key) }"
>
<a-menu-item v-for="item in group.items" :key="item.key">
<i :class="item.icon" class="mr-2 text-[12px] opacity-80" />
{{ item.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</nav>
<div class="admin-header-actions">
<a-segmented
v-model:value="menuLayout"
size="small"
class="admin-layout-seg"
:options="menuLayoutOptions"
/>
<a-button size="small" @click="openPreview">
<i class="fa-solid fa-eye mr-1 text-[#a88c6b]"></i>预览请柬
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-for="s in POSTER_SIDES" :key="s.key" @click="openPosterPreview(s.key)">
{{ s.label }}({{ s.key }}) {{ s.route }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
v-if="isConfigTab"
type="primary"
size="small"
:loading="saving"
@click="onSave"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
</a-button>
<a-dropdown>
<a-button size="small">{{ adminStore.user?.account || '管理员' }}<i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i></a-button>
<template #overlay>
<a-menu>
<a-menu-item key="logout" @click="onLogout"><i class="fa-solid fa-right-from-bracket mr-2"></i>退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-dropdown>
<a-button size="small">
<i class="fa-solid fa-scroll mr-1 text-[#a88c6b]"></i>预览海报 <i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-for="s in POSTER_SIDES" :key="s.key" @click="openPosterPreview(s.key)">
{{ s.label }}({{ s.key }}) {{ s.route }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
v-if="isConfigTab"
type="primary"
size="small"
:loading="saving"
@click="onSave"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
</a-button>
<a-dropdown>
<a-button size="small">{{ adminStore.user?.account || '管理员' }}<i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i></a-button>
<template #overlay>
<a-menu>
<a-menu-item key="logout" @click="onLogout"><i class="fa-solid fa-right-from-bracket mr-2"></i>退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</header>
<div class="admin-shell" :class="menuLayout === 'side' ? 'admin-shell--side' : 'admin-shell--top'">
<nav class="admin-nav" aria-label="后台菜单">
<nav v-if="menuLayout === 'side'" class="admin-nav" aria-label="后台菜单">
<div
v-for="group in adminMenuGroups"
:key="group.key"
@@ -1205,6 +1239,11 @@ const menuLayout = ref(readStoredMenuLayout())
watch(menuLayout, (v) => {
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
})
function isMenuGroupActive(group) {
return Array.isArray(group?.items) && group.items.some((item) => item.key === activeTab.value)
}
const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
@@ -2248,6 +2287,66 @@ onUnmounted(() => {
.admin-layout-seg :deep(.ant-segmented-item-selected) {
color: #a88c6b;
}
.admin-header {
padding: 10px 16px 10px;
}
.admin-header-row {
display: flex;
align-items: center;
gap: 12px 16px;
flex-wrap: wrap;
}
.admin-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.admin-top-menu {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
padding: 2px 0;
}
.admin-top-menu::-webkit-scrollbar { display: none; }
.admin-top-menu-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
color: #5c5348;
font-size: 13px;
letter-spacing: 0.04em;
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.admin-top-menu-trigger:hover {
background: rgba(168, 140, 107, 0.08);
color: #967b5c;
}
.admin-top-menu-trigger.active {
color: #a88c6b;
background: rgba(168, 140, 107, 0.12);
font-weight: 500;
}
.admin-top-menu-caret {
font-size: 9px;
opacity: 0.65;
}
.admin-top-dropdown :deep(.ant-dropdown-menu-item-selected) {
color: #a88c6b;
background: rgba(168, 140, 107, 0.12);
}
.admin-shell {
margin: 0 auto;
padding: 20px 16px 40px;
@@ -2267,15 +2366,6 @@ onUnmounted(() => {
border: 1px solid rgba(168, 140, 107, 0.14);
border-radius: 16px;
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
}
.admin-shell--top .admin-nav {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
padding: 14px 16px 12px;
margin-bottom: 16px;
}
.admin-shell--side .admin-nav {
position: sticky;
top: 68px;
width: 212px;
@@ -2291,27 +2381,21 @@ onUnmounted(() => {
margin-bottom: 6px;
padding: 0 6px;
}
.admin-shell--top .admin-nav-group {
min-width: 0;
}
.admin-shell--side .admin-nav-group + .admin-nav-group {
.admin-nav-group + .admin-nav-group {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed rgba(168, 140, 107, 0.22);
}
.admin-nav-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-shell--side .admin-nav-items {
flex-direction: column;
flex-wrap: nowrap;
gap: 6px;
}
.admin-nav-item {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
border: 1px solid transparent;
background: transparent;
color: #5c5348;
@@ -2323,9 +2407,6 @@ onUnmounted(() => {
transition: background 0.15s, color 0.15s, border-color 0.15s;
text-align: left;
}
.admin-shell--side .admin-nav-item {
width: 100%;
}
.admin-nav-item:hover {
background: rgba(168, 140, 107, 0.08);
color: #967b5c;
@@ -2351,29 +2432,33 @@ onUnmounted(() => {
color: #a88c6b;
text-align: center;
}
.admin-shell--top .admin-nav-credit {
width: 100%;
margin-top: 4px;
padding-top: 10px;
}
.admin-main {
min-width: 0;
flex: 1;
}
@media (max-width: 960px) {
.admin-top-menu {
order: 3;
flex: 1 1 100%;
border-top: 1px solid rgba(168, 140, 107, 0.12);
margin-top: 2px;
padding-top: 8px;
}
}
@media (max-width: 860px) {
.admin-shell--side {
flex-direction: column;
}
.admin-shell--side .admin-nav {
.admin-nav {
position: static;
width: 100%;
max-height: none;
}
.admin-shell--side .admin-nav-items {
.admin-nav-items {
flex-direction: row;
flex-wrap: wrap;
}
.admin-shell--side .admin-nav-item {
.admin-nav-item {
width: auto;
}
}

View File

@@ -194,6 +194,127 @@ function applyPageMeta() {
}
}
function parseObjectPosition(value) {
const parts = String(value || '50% 50%').trim().split(/\s+/)
const map = { left: 0, top: 0, center: 50, right: 100, bottom: 100 }
const toPct = (token, fallback) => {
if (token == null) return fallback
if (map[token] != null) return map[token]
const n = parseFloat(token)
return Number.isFinite(n) ? n : fallback
}
return {
x: toPct(parts[0], 50),
y: toPct(parts[1] ?? parts[0], 50),
}
}
/** 按 object-fit / object-position 把「屏幕上看到的那一块」画到 canvas */
function drawObjectFittedImage(ctx, img, cw, ch, fit, posX, posY) {
const nw = img.naturalWidth
const nh = img.naturalHeight
if (!nw || !nh || cw <= 0 || ch <= 0) return
const mode = fit === 'contain' ? 'contain' : 'cover'
const scale = mode === 'contain'
? Math.min(cw / nw, ch / nh)
: Math.max(cw / nw, ch / nh)
const dw = nw * scale
const dh = nh * scale
const dx = (cw - dw) * (posX / 100)
const dy = (ch - dh) * (posY / 100)
ctx.drawImage(img, 0, 0, nw, nh, dx, dy, dw, dh)
}
/**
* html2canvas 不尊重 object-fit/object-position会把整张原图塞进框里。
* 截图前把 cover/contain 图烘焙成「当前可见裁切」,截完再还原。
*/
async function bakeObjectFitImages(root) {
const imgs = [...root.querySelectorAll('img')].filter((img) => {
const fit = getComputedStyle(img).objectFit
return fit === 'cover' || fit === 'contain'
})
if (!imgs.length) return () => {}
const loadCorsImage = (src) => new Promise((resolve, reject) => {
const im = new Image()
im.crossOrigin = 'anonymous'
im.onload = () => resolve(im)
im.onerror = () => reject(new Error('image load failed'))
im.src = src
})
const restores = []
for (const img of imgs) {
const rect = img.getBoundingClientRect()
const w = Math.max(1, Math.round(rect.width))
const h = Math.max(1, Math.round(rect.height))
const cs = getComputedStyle(img)
const { x: posX, y: posY } = parseObjectPosition(cs.objectPosition)
const fit = cs.objectFit === 'contain' ? 'contain' : 'cover'
const src = img.currentSrc || img.src
if (!src) continue
let source = img
if (!img.naturalWidth) {
try { source = await loadCorsImage(src) } catch { continue }
}
const bakeScale = Math.min(3, Math.max(2, window.devicePixelRatio || 2))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(w * bakeScale))
canvas.height = Math.max(1, Math.round(h * bakeScale))
const ctx = canvas.getContext('2d')
if (!ctx) continue
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.scale(bakeScale, bakeScale)
let dataUrl = ''
try {
drawObjectFittedImage(ctx, source, w, h, fit, posX, posY)
dataUrl = canvas.toDataURL('image/png')
} catch {
// 可能被跨域污染:改用带 CORS 的图重试
try {
source = await loadCorsImage(src)
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.scale(bakeScale, bakeScale)
drawObjectFittedImage(ctx, source, w, h, fit, posX, posY)
dataUrl = canvas.toDataURL('image/png')
} catch {
continue
}
}
if (!dataUrl) continue
const prev = {
src: img.getAttribute('src'),
objectFit: img.style.objectFit,
objectPosition: img.style.objectPosition,
width: img.style.width,
height: img.style.height,
}
img.setAttribute('src', dataUrl)
img.style.objectFit = 'fill'
img.style.objectPosition = '50% 50%'
img.style.width = `${w}px`
img.style.height = `${h}px`
restores.push(() => {
if (prev.src != null) img.setAttribute('src', prev.src)
else img.removeAttribute('src')
img.style.objectFit = prev.objectFit
img.style.objectPosition = prev.objectPosition
img.style.width = prev.width
img.style.height = prev.height
})
}
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
return () => restores.forEach((fn) => fn())
}
async function mountCaptureQr(host) {
const isSplit = host.classList.contains('sp-card')
const target = isSplit
@@ -249,25 +370,42 @@ async function savePosterImage() {
const el = document.querySelector('[data-poster-capture]')
if (!el) return
savingImg.value = true
let cleanup = null
let cleanupQr = null
let cleanupBake = null
try {
cleanup = await mountCaptureQr(el)
cleanupBake = await bakeObjectFitImages(el)
cleanupQr = await mountCaptureQr(el)
// 按元素实际宽度抬升倍率:目标约 1600px 宽,最高 3x避免手机导出糊成一片
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
const scale = Math.min(3, Math.max(2.75, 1600 / baseW))
const canvas = await html2canvas(el, {
useCORS: true,
allowTaint: true,
backgroundColor: '#7a0000',
scale: Math.min(2, window.devicePixelRatio || 2),
scale,
logging: false,
imageTimeout: 20000,
removeContainer: true,
ignoreElements: (node) => !!node?.hasAttribute?.('data-html2canvas-ignore'),
})
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('导出失败'))),
'image/png',
)
})
const a = document.createElement('a')
a.download = `婚礼海报-${posterSideMeta(cfg.side).label}-${Date.now()}.png`
a.href = canvas.toDataURL('image/png')
a.href = URL.createObjectURL(blob)
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
} catch (e) {
console.error(e)
window.alert('保存失败,请稍后重试(跨域图片可能导致无法导出)')
} finally {
cleanup?.()
cleanupQr?.()
cleanupBake?.()
savingImg.value = false
}
}

View File

@@ -42,6 +42,7 @@
:src="cfg.heroImg"
alt=""
class="hb-hero-img"
crossorigin="anonymous"
:style="{ objectPosition: heroObjectPosition }"
/>
<div v-else class="hb-hero-placeholder">婚礼照片</div>

View File

@@ -13,6 +13,7 @@
v-if="cfg.heroImg"
:src="cfg.heroImg"
alt=""
crossorigin="anonymous"
:style="{ objectPosition: heroObjectPosition }"
/>
<div v-else class="sp-photo-ph">婚礼照片</div>