feat(home-banner): 轮播支持 PC/移动两端分图与热区跳转,热区图悬浮放大;修复上传压缩压白透明 PNG

This commit is contained in:
yml2213
2026-10-16 16:02:29 +08:00
parent b5867e3c5e
commit 9f8c0f9cb2
21 changed files with 2217 additions and 272 deletions
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import {
BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
createBannerHotspot,
hasBannerImage,
mergeHomeConfig,
normalizeHomeBanner,
} from './homeConfig'
describe('normalizeHomeBanner', () => {
it('把旧的扁平 image_url 结构升级为 PC / 移动两端共用同一张图', () => {
const banner = normalizeHomeBanner({
title: '旧结构',
eyebrow: '首页',
tone: 'green',
image_url: ' https://cdn.example/legacy.png ',
})
expect(banner.pc.image_url).toBe('https://cdn.example/legacy.png')
expect(banner.mobile.image_url).toBe('https://cdn.example/legacy.png')
expect(banner.tone).toBe('green')
expect(banner.enabled).toBe(true)
expect(banner.pc.hotspots).toEqual([])
})
it('两端分别配图时互不覆盖', () => {
const banner = normalizeHomeBanner({
title: '两端分图',
pc: { image_url: 'https://cdn.example/pc.png' },
mobile: { image_url: 'https://cdn.example/mobile.png' },
})
expect(banner.pc.image_url).toBe('https://cdn.example/pc.png')
expect(banner.mobile.image_url).toBe('https://cdn.example/mobile.png')
})
it('只配移动图时 PC 端不会误用移动图', () => {
const banner = normalizeHomeBanner({
title: '仅移动图',
mobile: { image_url: 'https://cdn.example/mobile.png' },
})
expect(banner.pc.image_url).toBe('')
expect(banner.mobile.image_url).toBe('https://cdn.example/mobile.png')
expect(hasBannerImage(banner.pc)).toBe(false)
})
it('收敛热区坐标并丢弃无效热区', () => {
const banner = normalizeHomeBanner({
title: '热区',
pc: {
image_url: '/pc.png',
hotspots: [
{
id: ' h1 ',
x: 90,
y: 95,
width: 20,
height: 20,
action: { type: 'route', target: '/x' },
},
{ id: 'zero', y: 10, width: 0, height: 10, action: { type: 'route', target: '/x' } },
],
},
})
expect(banner.pc.hotspots).toHaveLength(1)
expect(banner.pc.hotspots[0]).toMatchObject({ id: 'h1', x: 80, y: 80 })
})
it('缺失目标的跳转退化为不跳转,避免死热区', () => {
const banner = normalizeHomeBanner({
title: '死区',
pc: { image_url: '/pc.png', action: { type: 'route', target: ' ' } },
mobile: { image_url: '/m.png', action: { type: 'link', target: 'https://a.example' } },
})
expect(banner.pc.action).toEqual({ type: 'none', target: '', open_new_tab: false })
expect(banner.mobile.action).toEqual({
type: 'link',
target: 'https://a.example',
open_new_tab: false,
})
})
it('热区按钮图去空格,悬浮倍数默认与收敛', () => {
const banner = normalizeHomeBanner({
title: '按钮图',
pc: {
image_url: '/pc.png',
hotspots: [
{ id: 'a', width: 20, height: 20, image_url: ' https://cdn.example/btn.png ' },
{ id: 'b', width: 20, height: 20, hover_scale: 9 },
{ id: 'c', width: 20, height: 20, hover_scale: 0.5 },
],
},
})
expect(banner.pc.hotspots[0]?.image_url).toBe('https://cdn.example/btn.png')
expect(banner.pc.hotspots[0]?.hover_scale).toBe(BANNER_HOTSPOT_DEFAULT_HOVER_SCALE)
expect(banner.pc.hotspots[1]?.hover_scale).toBe(1.5)
expect(banner.pc.hotspots[2]?.hover_scale).toBe(1)
})
it('保留停用标记', () => {
expect(normalizeHomeBanner({ title: '停用', enabled: false }).enabled).toBe(false)
})
})
describe('mergeHomeConfig', () => {
it('过滤掉既无文案也无图片的空轮播', () => {
const config = mergeHomeConfig({
banners: [
{ title: '有效' },
{ title: ' ', pc: { image_url: 'https://cdn.example/pc.png' } },
{ title: '' },
],
})
// 只有图片没有文案的轮播要保留,全空的条目被丢弃
expect(config.banners.map(item => item.pc.image_url)).toEqual([
'',
'https://cdn.example/pc.png',
])
})
it('全部无效时回落到默认轮播', () => {
const config = mergeHomeConfig({ banners: [{ title: '' }] })
expect(config.banners.length).toBeGreaterThan(0)
expect(config.banners[0]?.pc.image_url).toBe('')
})
})
describe('createBannerHotspot', () => {
it('生成带默认行为的热区,便于后台直接框选', () => {
const hotspot = createBannerHotspot({ x: 10, y: 12 })
expect(hotspot.id).toBeTruthy()
expect(hotspot.action).toEqual({ type: 'none', target: '', open_new_tab: false })
expect(hotspot.image_url).toBe('')
expect(hotspot.hover_scale).toBe(BANNER_HOTSPOT_DEFAULT_HOVER_SCALE)
expect(hotspot).toMatchObject({ x: 10, y: 12, width: 24, height: 24 })
})
})
+191 -22
View File
@@ -3,13 +3,190 @@ import type { ApiResponse } from '@/shared/types/types'
import { normalizeAnnouncements } from '@/shared/utils/announcements'
import { mergeListingPublishOptions, type ListingPublishOptions } from './listingOptions'
/** 轮播出图规范:PC 1600×400(4:1)、移动端 750×375(2:1),两端容器严格等比,标准图零裁切。 */
export const HOME_BANNER_PC_RATIO = { width: 1600, height: 400 }
export const HOME_BANNER_MOBILE_RATIO = { width: 750, height: 375 }
export type BannerActionType = 'none' | 'route' | 'link' | 'preview'
/** 点击行为:站内路径 / 外部链接 / 查看大图 / 不跳转。 */
export interface BannerAction {
type: BannerActionType
target: string
open_new_tab: boolean
}
/** 热区:相对图片的百分比坐标(0-100),与出图分辨率解耦。 */
export interface BannerHotspot {
id: string
label: string
x: number
y: number
width: number
height: number
/** 热区按钮图(透明 PNG):留空则用半透明高亮框表示可点击。 */
image_url: string
/** 悬浮放大倍数:1 表示不放大,建议 1.04-1.08。 */
hover_scale: number
action: BannerAction
}
/** 单端视觉配置:图片 + 整图点击行为 + 热区(热点区域)。 */
export interface BannerVisual {
image_url: string
alt: string
action: BannerAction
hotspots: BannerHotspot[]
}
export interface HomeBannerSlide {
eyebrow: string
title: string
badge: string
pill: string
tone: string
image_url?: string
enabled: boolean
pc: BannerVisual
mobile: BannerVisual
}
export const bannerActionOptions: { value: BannerActionType; label: string }[] = [
{ value: 'none', label: '不跳转' },
{ value: 'route', label: '站内路径' },
{ value: 'link', label: '外部链接' },
{ value: 'preview', label: '查看大图' },
]
export const bannerActionHints: Record<BannerActionType, string> = {
none: '点击无动作;未配置热区时仍可点击查看大图',
route: '站内路径,如 /listings、/m/announcements',
link: '外部链接,需以 http:// 或 https:// 开头',
preview: '点击放大查看原图',
}
const BANNER_ACTION_TYPES: BannerActionType[] = ['none', 'route', 'link', 'preview']
export function createEmptyBannerAction(): BannerAction {
return { type: 'none', target: '', open_new_tab: false }
}
export function createEmptyBannerVisual(): BannerVisual {
return { image_url: '', alt: '', action: createEmptyBannerAction(), hotspots: [] }
}
export const BANNER_HOTSPOT_DEFAULT_HOVER_SCALE = 1.06
const BANNER_HOTSPOT_MAX_HOVER_SCALE = 1.5
export function createBannerHotspot(partial: Partial<BannerHotspot> = {}): BannerHotspot {
return {
id: `hotspot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
label: '',
x: 6,
y: 20,
width: 24,
height: 24,
image_url: '',
hover_scale: BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
action: createEmptyBannerAction(),
...partial,
}
}
export function normalizeBannerAction(raw: unknown): BannerAction {
const source = isRecord(raw) ? raw : {}
const rawType = typeof source.type === 'string' ? source.type.trim() : ''
const type = BANNER_ACTION_TYPES.find(item => item === rawType) ?? 'none'
const target = typeof source.target === 'string' ? source.target.trim() : ''
// 目标缺失的跳转会变成「点了没反应」的死热区,直接退化为不跳转
if ((type === 'route' || type === 'link') && !target) {
return createEmptyBannerAction()
}
return { type, target, open_new_tab: source.open_new_tab === true }
}
export function normalizeBannerHotspot(raw: unknown, index: number): BannerHotspot | null {
if (!isRecord(raw)) return null
const width = clampPercent(raw.width)
const height = clampPercent(raw.height)
if (width <= 0 || height <= 0) return null
return {
id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `hotspot-${index + 1}`,
label: typeof raw.label === 'string' ? raw.label.trim() : '',
x: clampPercent(raw.x, 100 - width),
y: clampPercent(raw.y, 100 - height),
width,
height,
image_url: typeof raw.image_url === 'string' ? raw.image_url.trim() : '',
hover_scale: normalizeHotspotHoverScale(raw.hover_scale),
action: normalizeBannerAction(raw.action),
}
}
/** 悬浮放大倍数:缺失/非法回落默认值,上限 1.5 避免热区盖住整张图。 */
function normalizeHotspotHoverScale(value: unknown): number {
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num) || num <= 0) return BANNER_HOTSPOT_DEFAULT_HOVER_SCALE
const clamped = Math.min(Math.max(num, 1), BANNER_HOTSPOT_MAX_HOVER_SCALE)
return Math.round(clamped * 100) / 100
}
/**
* 归一化单端视觉配置。
* `legacyImageUrl` 兼容早期扁平结构(只有一张 image_url),保证历史配置两端都还能出图。
*/
export function normalizeBannerVisual(raw: unknown, legacyImageUrl = ''): BannerVisual {
const source = isRecord(raw) ? raw : {}
const imageUrl = typeof source.image_url === 'string' ? source.image_url.trim() : ''
const rawHotspots = Array.isArray(source.hotspots) ? source.hotspots : []
return {
image_url: imageUrl || legacyImageUrl,
alt: typeof source.alt === 'string' ? source.alt.trim() : '',
action: normalizeBannerAction(source.action),
hotspots: rawHotspots
.map((item, index) => normalizeBannerHotspot(item, index))
.filter((item): item is BannerHotspot => item !== null),
}
}
/** 归一化单个轮播:新结构(pc/mobile)优先,同时兼容旧的扁平 image_url 结构。 */
export function normalizeHomeBanner(raw: unknown): HomeBannerSlide {
const source = isRecord(raw) ? raw : {}
const legacyImageUrl = typeof source.image_url === 'string' ? source.image_url.trim() : ''
return {
eyebrow: normalizeText(source.eyebrow),
title: normalizeText(source.title),
badge: normalizeText(source.badge),
pill: normalizeText(source.pill),
tone: normalizeBannerTone(typeof source.tone === 'string' ? source.tone : ''),
enabled: source.enabled !== false,
pc: normalizeBannerVisual(source.pc, legacyImageUrl),
mobile: normalizeBannerVisual(source.mobile, legacyImageUrl),
}
}
/** 该端是否配了图(配图才走等比图片容器,否则退回文字卡片)。 */
export function hasBannerImage(visual: BannerVisual): boolean {
return Boolean(visual.image_url.trim())
}
/** 热区是否可点击:配置了真实行为才算命中,否则让点击穿透到整图。 */
export function isBannerActionInteractive(action: BannerAction): boolean {
return action.type !== 'none'
}
function normalizeText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function clampPercent(value: unknown, max = 100): number {
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num)) return 0
const clamped = Math.min(Math.max(num, 0), max)
return Math.round(clamped * 100) / 100
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export interface AWRecycleConfig {
@@ -61,7 +238,9 @@ export const defaultHomeBanners: HomeBannerSlide[] = [
badge: 'HOT',
pill: '新人领券最高减 20 元',
tone: 'blue',
image_url: '',
enabled: true,
pc: createEmptyBannerVisual(),
mobile: createEmptyBannerVisual(),
},
{
eyebrow: '平台担保交易',
@@ -69,7 +248,9 @@ export const defaultHomeBanners: HomeBannerSlide[] = [
badge: 'SAFE',
pill: '租号前先验实名与资料',
tone: 'green',
image_url: '',
enabled: true,
pc: createEmptyBannerVisual(),
mobile: createEmptyBannerVisual(),
},
{
eyebrow: '高效筛选',
@@ -77,7 +258,9 @@ export const defaultHomeBanners: HomeBannerSlide[] = [
badge: 'FAST',
pill: '支持扫码号与账密号',
tone: 'orange',
image_url: '',
enabled: true,
pc: createEmptyBannerVisual(),
mobile: createEmptyBannerVisual(),
},
]
@@ -91,18 +274,8 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
const announcements = normalizeAnnouncements(config?.announcements)
const banners =
config?.banners
?.map(item => {
const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>)
return {
eyebrow: banner.eyebrow?.trim() || '',
title: banner.title?.trim() || '',
badge: banner.badge?.trim() || '',
pill: banner.pill?.trim() || '',
tone: normalizeBannerTone(banner.tone),
image_url: banner.image_url?.trim() || '',
}
})
.filter(item => item.title || item.image_url) || []
?.map(item => normalizeHomeBanner(item))
.filter(item => item.title || hasBannerImage(item.pc) || hasBannerImage(item.mobile)) || []
const awRaw = config?.aw_recycle
const awRecycle: AWRecycleConfig = {
@@ -150,11 +323,7 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
}
}
function isBannerLike(value: unknown): value is Partial<HomeBannerSlide> {
return typeof value === 'object' && value !== null
}
function normalizeBannerTone(tone?: string) {
if (tone === 'green' || tone === 'orange') return tone
function normalizeBannerTone(tone: string) {
if (tone === 'green' || tone === 'orange' || tone === 'gold') return tone
return 'blue'
}
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
isBannerActionInteractive,
type BannerAction,
type BannerHotspot,
type BannerVisual,
} from '@/features/listings/api/homeConfig'
const props = defineProps<{
visual: BannerVisual
}>()
const emit = defineEmits<{
trigger: [action: BannerAction]
}>()
/** 只渲染配置了真实行为的热区,其余保持点击穿透,交给整图行为处理。 */
const hotspots = computed(() =>
props.visual.hotspots.filter(item => isBannerActionInteractive(item.action))
)
function hotspotStyle(hotspot: BannerHotspot) {
return {
left: `${hotspot.x}%`,
top: `${hotspot.y}%`,
width: `${hotspot.width}%`,
height: `${hotspot.height}%`,
}
}
/** 悬浮放大倍数通过 CSS 变量下传,运营可按按钮图大小微调。 */
function imageStyle(hotspot: BannerHotspot) {
return { '--hotspot-hover-scale': String(hotspot.hover_scale) }
}
function hasHotspotImage(hotspot: BannerHotspot) {
return Boolean(hotspot.image_url.trim())
}
</script>
<template>
<div v-if="hotspots.length" class="banner-hotspot-layer">
<button
v-for="item in hotspots"
:key="item.id"
class="banner-hotspot"
:class="{ 'has-image': hasHotspotImage(item) }"
type="button"
:style="hotspotStyle(item)"
:aria-label="item.label || '查看详情'"
:title="item.label || undefined"
@click.stop="emit('trigger', item.action)"
>
<img
v-if="hasHotspotImage(item)"
class="banner-hotspot-image"
:src="item.image_url"
:alt="item.label || ''"
:style="imageStyle(item)"
draggable="false"
/>
</button>
</div>
</template>
<style scoped>
.banner-hotspot-layer {
position: absolute;
inset: 0;
z-index: 2;
}
.banner-hotspot {
position: absolute;
margin: 0;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition:
background 0.15s ease,
box-shadow 0.15s ease;
}
/* 无按钮图时用半透明高亮提示「这里可点」 */
.banner-hotspot:not(.has-image):hover,
.banner-hotspot:not(.has-image):focus-visible {
outline: none;
background: rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.85);
}
/* 有按钮图时按图片展示,悬浮轻微放大 + 投影(与运营海报按钮一致的手感) */
.banner-hotspot.has-image {
border-radius: 0;
}
.banner-hotspot-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
transform-origin: center;
transition:
transform 0.2s ease,
filter 0.2s ease;
}
.banner-hotspot.has-image:hover .banner-hotspot-image,
.banner-hotspot.has-image:focus-visible .banner-hotspot-image {
transform: scale(var(--hotspot-hover-scale, 1.06));
filter: drop-shadow(0 6px 14px rgba(15, 23, 42, 0.35));
}
.banner-hotspot.has-image:active .banner-hotspot-image {
transform: scale(calc(var(--hotspot-hover-scale, 1.06) * 0.97));
}
@media (prefers-reduced-motion: reduce) {
.banner-hotspot-image {
transition: none;
}
}
</style>
@@ -1,14 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
interface HomeBannerSlide {
title?: string
eyebrow?: string
pill?: string
badge?: string
tone?: string
image_url?: string
}
import {
hasBannerImage,
type BannerAction,
type BannerVisual,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import { useBannerAction } from '@/features/listings/composables/useBannerAction'
import BannerHotspotLayer from './BannerHotspotLayer.vue'
interface StatItem {
label: string
@@ -23,20 +22,18 @@ interface Props {
const props = defineProps<Props>()
// 轮播高度由 CSS 变量统一控制,方便大屏/窄屏按比例收敛。
const carouselHeight = 'var(--home-hero-height, 340px)'
const { run } = useBannerAction()
const previewVisible = ref(false)
const previewIndex = ref(0)
/** PC 端统一使用桌面图(1600×400),保证 4:1 容器内零裁切。 */
const previewUrls = computed(() =>
props.banners
.map(slide => slide.image_url?.trim() || '')
.filter((url): url is string => Boolean(url))
props.banners.map(slide => slide.pc.image_url.trim()).filter((url): url is string => Boolean(url))
)
function openPreview(slide: HomeBannerSlide) {
const url = slide.image_url?.trim()
const url = slide.pc.image_url.trim()
if (!url) return
const index = previewUrls.value.indexOf(url)
if (index < 0) return
@@ -49,30 +46,47 @@ function closePreview() {
}
function hasSlideCopy(slide: HomeBannerSlide) {
return Boolean(slide.eyebrow?.trim() || slide.title?.trim() || slide.pill?.trim())
return Boolean(slide.eyebrow.trim() || slide.title.trim() || slide.pill.trim())
}
/** 热区/整图点击:配置跳转就跳,配了查看大图就看大图,都没配则退回查看大图。 */
function handleAction(slide: HomeBannerSlide, action: BannerAction) {
if (action.type === 'preview') {
openPreview(slide)
return
}
if (run(action)) return
openPreview(slide)
}
/** 光标反馈:可跳转用 pointer,可看大图用 zoom-in。 */
function slideCursorClasses(visual: BannerVisual) {
if (!hasBannerImage(visual)) return {}
const actionable = visual.action.type === 'route' || visual.action.type === 'link'
return { 'is-actionable': actionable, 'is-previewable': !actionable }
}
</script>
<template>
<section class="hero-board">
<el-carousel :height="carouselHeight" :interval="3600">
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
<el-carousel :interval="3600">
<el-carousel-item v-for="slide in banners" :key="slide.pc.image_url || slide.title">
<div
class="hero-slide"
:class="[
`tone-${slide.tone}`,
slideCursorClasses(slide.pc),
{
'has-image': slide.image_url,
'has-image': hasBannerImage(slide.pc),
'has-copy': hasSlideCopy(slide),
'is-previewable': !!slide.image_url,
},
]"
@click="openPreview(slide)"
@click="handleAction(slide, slide.pc.action)"
>
<img
v-if="slide.image_url"
:src="slide.image_url"
:alt="slide.title || slide.eyebrow || '首页轮播'"
v-if="hasBannerImage(slide.pc)"
:src="slide.pc.image_url"
:alt="slide.pc.alt || slide.title || slide.eyebrow || '首页轮播'"
/>
<div class="hero-copy">
<span v-if="slide.eyebrow">{{ slide.eyebrow }}</span>
@@ -80,6 +94,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
<p v-if="slide.pill">{{ slide.pill }}</p>
</div>
<em v-if="slide.badge">{{ slide.badge }}</em>
<BannerHotspotLayer :visual="slide.pc" @trigger="action => handleAction(slide, action)" />
</div>
</el-carousel-item>
</el-carousel>
@@ -104,22 +119,25 @@ function hasSlideCopy(slide: HomeBannerSlide) {
</template>
<style scoped>
/* PC 出图规范 1600×400(4:1):容器靠 aspect-ratio 严格等比,换分辨率也不裁切。 */
.hero-board {
--home-hero-height: 340px;
position: relative;
min-width: 0;
width: 100%;
aspect-ratio: 1600 / 400;
border-radius: 14px;
overflow: hidden;
background: #ffffff;
}
.hero-board :deep(.el-carousel) {
height: 100%;
min-width: 0;
}
/* 覆盖 el-carousel 自带的 300px 容器高度与 height 属性内联样式 */
.hero-board :deep(.el-carousel__container) {
height: var(--home-hero-height, 340px);
height: 100% !important;
overflow: hidden;
}
@@ -142,6 +160,10 @@ function hasSlideCopy(slide: HomeBannerSlide) {
cursor: zoom-in;
}
.hero-slide.is-actionable {
cursor: pointer;
}
.hero-slide.tone-blue {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 56%, #fff7ed 100%);
}
@@ -159,6 +181,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.hero-slide.has-image.has-copy::after {
@@ -264,20 +287,12 @@ function hasSlideCopy(slide: HomeBannerSlide) {
}
@media (max-width: 1440px) {
.hero-board {
--home-hero-height: 300px;
}
.hero-copy h1 {
font-size: 26px;
}
}
@media (max-width: 1024px) {
.hero-board {
--home-hero-height: 240px;
}
.hero-slide {
padding: 22px 28px;
}
@@ -0,0 +1,29 @@
import { useRouter } from 'vue-router'
import type { BannerAction } from '@/features/listings/api/homeConfig'
/**
* 轮播点击行为统一执行器:站内路径走路由,外部链接走 window.open。
* 返回是否真正执行了跳转,调用方可据此决定兜底行为(例如查看大图)。
*/
export function useBannerAction() {
const router = useRouter()
function run(action: BannerAction): boolean {
const target = action.target.trim()
if (action.type === 'route' && target) {
if (action.open_new_tab) {
window.open(target, '_blank', 'noopener')
return true
}
void router.push(target)
return true
}
if (action.type === 'link' && target) {
window.open(target, action.open_new_tab ? '_blank' : '_self', 'noopener')
return true
}
return false
}
return { run }
}
@@ -8,6 +8,7 @@ import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
normalizeHomeBanner,
type AWRecycleConfig,
type CooperationFeedbackConfig,
type HomeBannerSlide,
@@ -60,10 +61,11 @@ const displayAnnouncements = computed(() => {
})
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
const displayBanners = computed<HomeBannerSlide[]>(() => {
if (siteStore.isDistribution && siteStore.banners.length > 0) {
return mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
}
return banners.value
const list =
siteStore.isDistribution && siteStore.banners.length > 0
? mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
: banners.value
return list.filter(slide => slide.enabled !== false)
})
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
const awRecycleDialogVisible = ref(false)
@@ -399,7 +401,7 @@ function restoreFromCache(): boolean {
})
if (pcHomeCache.homeConfigLoaded) {
announcements.value = pcHomeCache.announcements.slice()
banners.value = pcHomeCache.banners.map(slide => ({ ...slide }))
banners.value = pcHomeCache.banners.map(slide => normalizeHomeBanner(slide))
awRecycle.value = { ...pcHomeCache.awRecycle }
cooperationFeedback.value = { ...pcHomeCache.cooperationFeedback }
publishOptions.value = pcHomeCache.publishOptions
@@ -285,9 +285,15 @@
background: #ff6a00;
}
/* 移动出图规范 750×375(2:1):只要有一条配图,整条轮播统一按 2:1 等比,标准图零裁切 */
.banner-swipe.has-image {
aspect-ratio: 2 / 1;
}
.mobile-banner {
position: relative;
display: flex;
height: 100%;
min-height: 128px;
align-items: flex-end;
padding: 16px 16px 24px;
@@ -300,6 +306,10 @@
cursor: zoom-in;
}
.mobile-banner.is-actionable {
cursor: pointer;
}
.mobile-banner.tone-warm {
background: #9a3412;
}
@@ -328,12 +338,8 @@
object-fit: cover;
}
.banner-swipe.is-poster {
height: 168px;
}
.mobile-banner.is-poster {
height: 168px;
height: 100%;
min-height: 0;
padding: 0;
}
@@ -387,9 +393,11 @@
font-weight: 700;
}
/* 大图预览按移动出图规范 2:1 展示,并把整张图完整放进框内(不裁切) */
.banner-lightbox-frame {
width: 100%;
aspect-ratio: 2.15 / 1;
aspect-ratio: 2 / 1;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
@@ -397,7 +405,7 @@
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
object-position: center;
}
@@ -19,10 +19,15 @@ import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
hasBannerImage,
normalizeHomeBanner,
type AWRecycleConfig,
type BannerAction,
type CooperationFeedbackConfig,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import BannerHotspotLayer from '@/features/listings/components/BannerHotspotLayer.vue'
import { useBannerAction } from '@/features/listings/composables/useBannerAction'
import { showToast } from 'vant'
import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue'
@@ -72,6 +77,7 @@ const route = useRoute()
const session = useSessionStore()
const siteStore = useDistributionSiteStore()
const homeCache = useMobileHomeCacheStore()
const { run: runBannerAction } = useBannerAction()
const scrollEl = ref<HTMLElement | null>(null)
const loading = ref(false)
const loadingMore = ref(false)
@@ -102,16 +108,18 @@ const displayAnnouncements = computed(() => {
})
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
const displayBanners = computed<HomeBannerSlide[]>(() => {
if (siteStore.isDistribution && siteStore.banners.length > 0) {
return mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
}
return bannerSlides.value
const list =
siteStore.isDistribution && siteStore.banners.length > 0
? mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
: bannerSlides.value
return list.filter(slide => slide.enabled !== false)
})
const bannerPreviewVisible = ref(false)
const bannerPreviewIndex = ref(0)
/** 移动端统一使用移动图(750×375),保证 2:1 容器内零裁切。 */
const bannerPreviewImages = computed(() =>
displayBanners.value
.map(slide => slide.image_url?.trim() || '')
.map(slide => slide.mobile.image_url.trim())
.filter((url): url is string => Boolean(url))
)
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
@@ -352,7 +360,7 @@ function restoreFromCache() {
function restoreHomeConfigFromCache() {
if (!homeCache.homeConfigLoaded) return false
announcements.value = homeCache.announcements.slice()
bannerSlides.value = homeCache.bannerSlides.map(slide => ({ ...slide }))
bannerSlides.value = homeCache.bannerSlides.map(slide => normalizeHomeBanner(slide))
publishOptions.value = homeCache.publishOptions
return true
}
@@ -503,7 +511,7 @@ function openCooperationFeedback() {
}
function openBannerPreview(slide: HomeBannerSlide) {
const url = slide.image_url?.trim()
const url = slide.mobile.image_url.trim()
if (!url) return
const startPosition = bannerPreviewImages.value.indexOf(url)
if (startPosition < 0) return
@@ -511,6 +519,16 @@ function openBannerPreview(slide: HomeBannerSlide) {
bannerPreviewVisible.value = true
}
/** 热区/整图点击:配置跳转就跳,配了查看大图就看大图,都没配则退回查看大图。 */
function handleBannerAction(slide: HomeBannerSlide, action: BannerAction) {
if (action.type === 'preview') {
openBannerPreview(slide)
return
}
if (runBannerAction(action)) return
openBannerPreview(slide)
}
function closeBannerPreview() {
bannerPreviewVisible.value = false
}
@@ -876,34 +894,41 @@ if (!restoreHomeConfigFromCache()) {
<p class="dist-card-sub">{{ siteStore.storefrontText.card_sub }}</p>
</div>
<!-- Banner 轮播 -->
<!-- Banner 轮播:统一按移动出图规范 750×375(2:1)等比展示 -->
<van-swipe
class="banner-swipe"
:class="{
'is-poster': displayBanners.some(slide => slide.image_url && !hasBannerCopy(slide)),
'has-image': displayBanners.some(slide => hasBannerImage(slide.mobile)),
}"
:autoplay="3600"
lazy-render
>
<van-swipe-item v-for="slide in displayBanners" :key="slide.image_url || slide.title">
<van-swipe-item
v-for="slide in displayBanners"
:key="slide.mobile.image_url || slide.title"
>
<div
class="mobile-banner"
:class="[
`tone-${slide.tone}`,
{
'has-image': slide.image_url,
'has-image': hasBannerImage(slide.mobile),
'has-copy': hasBannerCopy(slide),
'is-poster': !!slide.image_url && !hasBannerCopy(slide),
'is-previewable': !!slide.image_url,
'is-poster': hasBannerImage(slide.mobile) && !hasBannerCopy(slide),
'is-actionable':
slide.mobile.action.type === 'route' || slide.mobile.action.type === 'link',
'is-previewable': !(
slide.mobile.action.type === 'route' || slide.mobile.action.type === 'link'
),
},
]"
@click="openBannerPreview(slide)"
@click="handleBannerAction(slide, slide.mobile.action)"
>
<img
v-if="slide.image_url"
v-if="hasBannerImage(slide.mobile)"
class="banner-image"
:src="slide.image_url"
:alt="slide.title || slide.eyebrow || '首页轮播图'"
:src="slide.mobile.image_url"
:alt="slide.mobile.alt || slide.title || slide.eyebrow || '首页轮播图'"
/>
<div class="banner-copy">
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
@@ -911,6 +936,10 @@ if (!restoreHomeConfigFromCache()) {
<span v-if="slide.pill" class="banner-pill">{{ slide.pill }}</span>
</div>
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
<BannerHotspotLayer
:visual="slide.mobile"
@trigger="action => handleBannerAction(slide, action)"
/>
</div>
</van-swipe-item>
</van-swipe>