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

@@ -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;
}