1. 迎宾海报

This commit is contained in:
李琦
2026-08-04 12:24:51 +08:00
parent 5a25304774
commit ef77bb4870
3 changed files with 422 additions and 49 deletions

View File

@@ -216,6 +216,7 @@
:src="displayAt(imgIdx)"
class="preview-img g-heart-img"
decoding="async"
:style="{ objectPosition: heartFocusCssAt(page, imgIdx) }"
@click.stop="previewImage(originalAt(imgIdx))"
/>
</div>
@@ -230,6 +231,7 @@
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { borderClass } from '@/composables/borderStyles'
import { createSequentialMount } from '@/composables/useSequentialMount'
import { HEART_SLOTS, heartSlotStyle, heartFocusCssAt } from '@/composables/heartCollage'
const NINE_STEP_MS = 300
const NINE_SETTLE_MS = 400
@@ -264,23 +266,6 @@ const SEQUENTIAL_SELF_TYPES = new Set([
'heart-collage',
])
/** 爱心拼接槽位:百分比绝对定位,轮廓接近 ♥(顶开叉、中最宽、底收尖) */
const HEART_SLOTS = [
{ l: 10, t: 0, w: 32, h: 13 }, // 0 左上叶
{ l: 58, t: 0, w: 32, h: 13 }, // 1 右上叶(中间约 16% 开叉)
{ l: 0, t: 14, w: 14, h: 15.5 }, // 2 左外上
{ l: 0, t: 30.5, w: 14, h: 15.5 }, // 3 左外下
{ l: 15, t: 14, w: 26, h: 32 }, // 4 左大图
{ l: 42, t: 14, w: 16, h: 32 }, // 5 中轴
{ l: 59, t: 14, w: 26, h: 32 }, // 6 右大图
{ l: 86, t: 14, w: 14, h: 15.5 }, // 7 右外上
{ l: 86, t: 30.5, w: 14, h: 15.5 }, // 8 右外下
{ l: 10, t: 47, w: 13, h: 16 }, // 9 左下收腰
{ l: 24, t: 47, w: 52, h: 16 }, // 10 中下横
{ l: 77, t: 47, w: 13, h: 16 }, // 11 右下收腰
{ l: 38, t: 64, w: 24, h: 14 }, // 12 心尖
]
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格:开启串行 v-reveal关闭则一次展示 */
@@ -659,15 +644,6 @@ function heartReveal(i) {
return 'up'
}
function heartSlotStyle(slot) {
return {
left: `${slot.l}%`,
top: `${slot.t}%`,
width: `${slot.w}%`,
height: `${slot.h}%`,
}
}
</script>
<style scoped>

View File

@@ -0,0 +1,78 @@
import { clampHeroFocus, heroFocusCss } from '@/composables/posterDefaults'
/** 爱心拼接槽位:百分比绝对定位(顶开叉、中最宽、底收尖) */
export const HEART_SLOTS = [
{ l: 10, t: 0, w: 32, h: 13 }, // 0 左上叶
{ l: 58, t: 0, w: 32, h: 13 }, // 1 右上叶
{ l: 0, t: 14, w: 14, h: 15.5 }, // 2 左外上
{ l: 0, t: 30.5, w: 14, h: 15.5 }, // 3 左外下
{ l: 15, t: 14, w: 26, h: 32 }, // 4 左大图
{ l: 42, t: 14, w: 16, h: 32 }, // 5 中轴
{ l: 59, t: 14, w: 26, h: 32 }, // 6 右大图
{ l: 86, t: 14, w: 14, h: 15.5 }, // 7 右外上
{ l: 86, t: 30.5, w: 14, h: 15.5 }, // 8 右外下
{ l: 10, t: 47, w: 13, h: 16 }, // 9 左下收腰
{ l: 24, t: 47, w: 52, h: 16 }, // 10 中下横
{ l: 77, t: 47, w: 13, h: 16 }, // 11 右下收腰
{ l: 38, t: 64, w: 24, h: 14 }, // 12 心尖
]
export const HEART_SLOT_COUNT = HEART_SLOTS.length
export const HEART_SLOT_LABELS = [
'左上叶', '右上叶', '左外上', '左外下', '左大图', '中轴', '右大图',
'右外上', '右外下', '左下', '中下横', '右下', '心尖',
]
export function heartSlotStyle(slot) {
return {
left: `${slot.l}%`,
top: `${slot.t}%`,
width: `${slot.w}%`,
height: `${slot.h}%`,
}
}
export function heartSlotLabel(i) {
return HEART_SLOT_LABELS[i] || `${i + 1}`
}
export function heartFocusAt(page, i, axis = 'x') {
const arr = axis === 'y' ? page?.imagesFocusY : page?.imagesFocusX
const v = Array.isArray(arr) ? arr[i] : 50
return clampHeroFocus(v, 50)
}
export function heartFocusCssAt(page, i) {
return heroFocusCss(heartFocusAt(page, i, 'x'), heartFocusAt(page, i, 'y'))
}
/** 保证 imagesFocusX/Y 与 images 等长;非爱心版式则清除 */
export function syncImagesFocus(page) {
if (!page || page.type !== 'heart-collage' || !Array.isArray(page.images)) {
if (page) {
delete page.imagesFocusX
delete page.imagesFocusY
}
return
}
if (!Array.isArray(page.imagesFocusX)) page.imagesFocusX = []
if (!Array.isArray(page.imagesFocusY)) page.imagesFocusY = []
const n = page.images.length
while (page.imagesFocusX.length < n) page.imagesFocusX.push(50)
while (page.imagesFocusY.length < n) page.imagesFocusY.push(50)
for (let i = 0; i < n; i += 1) {
page.imagesFocusX[i] = clampHeroFocus(page.imagesFocusX[i], 50)
page.imagesFocusY[i] = clampHeroFocus(page.imagesFocusY[i], 50)
}
page.imagesFocusX.length = n
page.imagesFocusY.length = n
}
export function setHeartFocus(page, i, x, y) {
if (!page || page.type !== 'heart-collage') return
syncImagesFocus(page)
if (i < 0 || i >= page.images.length) return
page.imagesFocusX[i] = clampHeroFocus(x, 50)
page.imagesFocusY[i] = clampHeroFocus(y, 50)
}

View File

@@ -1094,13 +1094,82 @@
</div>
</div>
</template>
<template v-else>
<div v-if="page.type === 'heart-collage'" class="w-full text-[11px] text-[#8A8680] mb-1">
13 按心形槽位排列上叶 中段对称 下横 心尖建议竖图放中轴横图放上下叶
<template v-else-if="page.type === 'heart-collage'">
<div class="w-full">
<div class="text-[11px] text-[#8A8680] mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>
<strong>单击</strong>两格对调顺序格内<strong>拖拽</strong>调整展示焦点同海报
空格点击上传
</span>
<a-button
v-if="heartSwapSelected?.page === page"
size="small"
type="link"
class="!px-0 !h-auto !text-[#a88c6b]"
@click="heartSwapSelected = null"
>取消选择已选{{ heartSlotLabel(heartSwapSelected.idx) }}</a-button>
</div>
<div class="heart-admin-board">
<div
v-for="(slot, iidx) in HEART_SLOTS"
:key="iidx"
class="heart-admin-slot"
:style="heartSlotStyle(slot)"
>
<div
class="heart-admin-cell"
:class="{
empty: !(page.images[iidx] || page.imagesResized?.[iidx]),
selected: heartSwapSelected?.page === page && heartSwapSelected?.idx === iidx,
'swap-target': heartSwapSelected?.page === page && heartSwapSelected?.idx !== iidx,
dragging: heartFocusDrag?.page === page && heartFocusDrag?.idx === iidx && heartFocusDrag?.moved,
}"
@pointerdown="onHeartSlotPointerDown($event, page, iidx)"
>
<img
v-if="page.images[iidx] || page.imagesResized?.[iidx]"
:src="page.imagesResized?.[iidx] || page.images[iidx]"
alt=""
draggable="false"
:style="{ objectPosition: heartFocusCssAt(page, iidx) }"
/>
<div v-else class="heart-admin-empty">
<span class="text-[16px] leading-none">+</span>
<span>{{ heartSlotLabel(iidx) }}</span>
</div>
<span
v-if="page.images[iidx] || page.imagesResized?.[iidx]"
class="hero-focus-crosshair heart-admin-crosshair"
:style="{
left: heartFocusAt(page, iidx, 'x') + '%',
top: heartFocusAt(page, iidx, 'y') + '%',
}"
/>
<div class="heart-admin-toolbar">
<span class="heart-admin-label">{{ heartSlotLabel(iidx) }}</span>
<button
type="button"
class="heart-admin-btn"
title="上传/更换"
@click.stop="openUpload(u => setOriginalImageAt(page, iidx, u))"
>
<i class="fa-solid fa-image"></i>
</button>
</div>
<div
v-if="page.imagesResized?.[iidx]"
class="heart-admin-meta"
:title="page.imagesResizedMeta?.[iidx] || '展示已生成'"
>已缩放</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
<image-field v-model="page.images[iidx]"
:label="heartSlotLabel(page.type, iidx)"
:label="`图${iidx + 1}`"
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
:uploading="uploading" />
<div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
@@ -1110,7 +1179,6 @@
<button v-if="isFlexible(page.type) && page.images.length > 1"
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
@click="removeImage(page, iidx)" title="移除该图"></button>
<!-- 排序前移 / 后移 -->
<button v-if="iidx > 0"
class="absolute -bottom-2 left-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
@click="moveImage(page, iidx, -1)" title="前移"><i class="fa-solid fa-arrow-left"></i></button>
@@ -1483,6 +1551,11 @@ import {
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
posterSideMeta, posterLayoutMeta, posterRoute,
} from '@/composables/posterDefaults'
import {
HEART_SLOTS, HEART_SLOT_COUNT,
heartSlotStyle, heartSlotLabel, heartFocusAt, heartFocusCssAt,
syncImagesFocus, setHeartFocus,
} from '@/composables/heartCollage'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
@@ -1980,7 +2053,7 @@ function ensurePageShape(p) {
delete p.imagesResizedMeta
} else {
// 固定数量版式:一大五小(6) / 九宫格(9) / 爱心拼接(13);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'heart-collage': 13, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'heart-collage': HEART_SLOT_COUNT, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
const need = fixed[p.type] || 6
p.images = Array.isArray(p.images) ? p.images : []
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
@@ -1989,6 +2062,7 @@ function ensurePageShape(p) {
if (p.images.length > need) p.images.length = need
}
syncImagesResized(p)
syncImagesFocus(p)
delete p.img1
delete p.img2
delete p.img1Resized
@@ -1996,6 +2070,8 @@ function ensurePageShape(p) {
delete p.img1ResizedMeta
delete p.img2ResizedMeta
}
// 非爱心版式清掉焦点字段
if (p.type !== 'heart-collage') syncImagesFocus(p)
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.height = p.height || '100vh'
p.borderStyle = p.borderStyle || 'mat'
@@ -2010,43 +2086,146 @@ function isFlexible(type) {
return ['masonry', 'carousel', 'polaroid'].includes(type)
}
const HEART_SLOT_LABELS = [
'左上叶', '右上叶', '左外上', '左外下', '左大图', '中轴', '右大图',
'右外上', '右外下', '左下', '中下横', '右下', '心尖',
]
function heartSlotLabel(type, i) {
if (type === 'heart-collage' && HEART_SLOT_LABELS[i]) return HEART_SLOT_LABELS[i]
return `${i + 1}`
}
function addImage(page) {
if (!Array.isArray(page.images)) page.images = []
page.images.push('')
syncImagesResized(page)
syncImagesFocus(page)
}
function removeImage(page, i) {
if (page.images.length > 1) {
page.images.splice(i, 1)
if (Array.isArray(page.imagesResized)) page.imagesResized.splice(i, 1)
if (Array.isArray(page.imagesResizedMeta)) page.imagesResizedMeta.splice(i, 1)
if (Array.isArray(page.imagesFocusX)) page.imagesFocusX.splice(i, 1)
if (Array.isArray(page.imagesFocusY)) page.imagesFocusY.splice(i, 1)
syncImagesResized(page)
syncImagesFocus(page)
}
}
// 图片排序:前移(-1) / 后移(+1)
function moveImage(page, i, dir) {
const j = i + dir
if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
const t = page.images[i]; page.images[i] = page.images[j]; page.images[j] = t
const swap = (arr) => {
if (!Array.isArray(arr) || i >= arr.length || j >= arr.length) return
const t = arr[i]; arr[i] = arr[j]; arr[j] = t
}
swap(page.images)
syncImagesResized(page)
if (Array.isArray(page.imagesResized)) {
const r = page.imagesResized[i]
page.imagesResized[i] = page.imagesResized[j]
page.imagesResized[j] = r
swap(page.imagesResized)
swap(page.imagesResizedMeta)
syncImagesFocus(page)
swap(page.imagesFocusX)
swap(page.imagesFocusY)
}
const heartSwapSelected = ref(null)
const heartFocusDrag = ref(null)
const HEART_FOCUS_DRAG_THRESHOLD = 6
function swapHeartSlots(page, i, j) {
if (!page || !Array.isArray(page.images) || i === j) return
if (i < 0 || j < 0 || i >= page.images.length || j >= page.images.length) return
syncImagesResized(page)
syncImagesFocus(page)
const swap = (arr) => {
if (!Array.isArray(arr)) return
const t = arr[i]
arr[i] = arr[j]
arr[j] = t
}
if (Array.isArray(page.imagesResizedMeta)) {
const m = page.imagesResizedMeta[i]
page.imagesResizedMeta[i] = page.imagesResizedMeta[j]
page.imagesResizedMeta[j] = m
swap(page.images)
swap(page.imagesResized)
swap(page.imagesResizedMeta)
swap(page.imagesFocusX)
swap(page.imagesFocusY)
}
function applyHeartFocusFromClient(clientX, clientY) {
const drag = heartFocusDrag.value
if (!drag?.el || !drag.page) return
const rect = drag.el.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = ((clientX - rect.left) / rect.width) * 100
const y = ((clientY - rect.top) / rect.height) * 100
setHeartFocus(drag.page, drag.idx, x, y)
}
function onHeartSlotPointerDown(e, page, idx) {
if (e.target?.closest?.('.heart-admin-btn')) return
if (e.button != null && e.button !== 0) return
const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
// 空格:交给 pointerup 做「上传 / 对调」
if (empty) {
heartFocusDrag.value = {
page, idx, el: e.currentTarget,
startX: e.clientX, startY: e.clientY,
moved: false, empty: true,
}
window.addEventListener('pointerup', onHeartSlotPointerUp)
window.addEventListener('pointercancel', onHeartSlotPointerUp)
return
}
e.preventDefault()
heartFocusDrag.value = {
page, idx, el: e.currentTarget,
startX: e.clientX, startY: e.clientY,
moved: false, empty: false,
}
window.addEventListener('pointermove', onHeartSlotPointerMove)
window.addEventListener('pointerup', onHeartSlotPointerUp)
window.addEventListener('pointercancel', onHeartSlotPointerUp)
}
function onHeartSlotPointerMove(e) {
const drag = heartFocusDrag.value
if (!drag || drag.empty) return
const dx = e.clientX - drag.startX
const dy = e.clientY - drag.startY
if (!drag.moved && (dx * dx + dy * dy) < HEART_FOCUS_DRAG_THRESHOLD ** 2) return
drag.moved = true
applyHeartFocusFromClient(e.clientX, e.clientY)
}
function onHeartSlotClickAction(page, idx) {
const sel = heartSwapSelected.value
const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
if (empty && !(sel && sel.page === page)) {
openUpload((u) => setOriginalImageAt(page, idx, u))
return
}
if (!sel || sel.page !== page) {
heartSwapSelected.value = { page, idx }
return
}
if (sel.idx === idx) {
heartSwapSelected.value = null
return
}
swapHeartSlots(page, sel.idx, idx)
heartSwapSelected.value = null
}
function onHeartSlotPointerUp(e) {
const drag = heartFocusDrag.value
window.removeEventListener('pointermove', onHeartSlotPointerMove)
window.removeEventListener('pointerup', onHeartSlotPointerUp)
window.removeEventListener('pointercancel', onHeartSlotPointerUp)
heartFocusDrag.value = null
if (!drag?.page) return
// 拖过焦点:不对调
if (drag.moved) return
onHeartSlotClickAction(drag.page, drag.idx)
}
function clearHeartSlotPointer() {
heartFocusDrag.value = null
window.removeEventListener('pointermove', onHeartSlotPointerMove)
window.removeEventListener('pointerup', onHeartSlotPointerUp)
window.removeEventListener('pointercancel', onHeartSlotPointerUp)
}
function pageHasOriginal(page) {
@@ -2127,6 +2306,7 @@ async function batchResizeBasicImages() {
function setOriginalImageAt(page, iidx, url) {
page.images[iidx] = url
syncImagesResized(page)
syncImagesFocus(page)
page.imagesResized[iidx] = ''
page.imagesResizedMeta[iidx] = ''
}
@@ -3200,6 +3380,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', onResize)
onHeroFocusPointerUp()
clearHeartSlotPointer()
disposeVisitCharts()
})
</script>
@@ -3213,6 +3394,144 @@ onUnmounted(() => {
white-space: pre-line;
word-break: break-all;
}
.heart-admin-board {
position: relative;
width: 100%;
max-width: 520px;
aspect-ratio: 0.8 / 1;
margin: 0 auto 8px;
background:
radial-gradient(ellipse at 50% 30%, rgba(201, 120, 120, 0.06), transparent 55%),
#faf8f5;
border: 1px solid #e7e3db;
border-radius: 12px;
overflow: hidden;
}
.heart-admin-slot {
position: absolute;
min-width: 0;
min-height: 0;
padding: 1px;
box-sizing: border-box;
}
.heart-admin-cell {
position: relative;
width: 100%;
height: 100%;
border-radius: 4px;
overflow: hidden;
background: #f0ebe3;
border: 1px solid #e7e3db;
user-select: none;
touch-action: none;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
}
.heart-admin-cell.empty {
border-style: dashed;
border-color: rgba(168, 140, 107, 0.45);
background: #f7f4ef;
}
.heart-admin-cell.selected {
border-color: #a88c6b;
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.65);
z-index: 3;
}
.heart-admin-cell.swap-target:hover {
border-color: #c4a484;
box-shadow: 0 0 0 2px rgba(196, 164, 132, 0.45);
z-index: 2;
}
.heart-admin-cell.dragging {
cursor: grabbing;
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.55);
z-index: 4;
}
.heart-admin-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.heart-admin-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #a88c6b;
font-size: 10px;
line-height: 1.2;
padding: 2px;
text-align: center;
pointer-events: none;
}
.heart-admin-crosshair {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-width: 1.5px;
}
.heart-admin-toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2px;
padding: 2px 3px;
background: linear-gradient(transparent, rgba(40, 30, 20, 0.55));
z-index: 2;
pointer-events: none;
}
.heart-admin-label {
font-size: 9px;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.heart-admin-btn {
pointer-events: auto;
width: 18px;
height: 18px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: #a88c6b;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.heart-admin-btn:hover {
background: #a88c6b;
color: #fff;
}
.heart-admin-meta {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
font-size: 8px;
line-height: 1;
padding: 2px 3px;
border-radius: 3px;
background: rgba(168, 140, 107, 0.9);
color: #fff;
pointer-events: none;
}
.admin-layout-seg :deep(.ant-segmented-item-selected) {
color: #a88c6b;
}