feat(home-banner): 轮播支持 PC/移动两端分图与热区跳转,热区图悬浮放大;修复上传压缩压白透明 PNG
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user