1. 礼物特效
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user