feat(distribution-banner): 分销站点轮播支持 PC/移动端分图与热区配置

This commit is contained in:
yml2213
2026-10-17 14:47:00 +08:00
parent ab9d852bde
commit 790267e3eb
9 changed files with 1472 additions and 120 deletions
@@ -58,4 +58,71 @@ describe('mapDistributionHomeBanners', () => {
expect(slide?.pc.action.type).toBe('none')
expect(slide?.pc.hotspots).toEqual([])
})
it('supports pc and mobile images, copy and actions independently', () => {
const [slide] = mapDistributionHomeBanners(
[
{
eyebrow: '本店活动',
title: '周末特惠',
badge: 'NEW',
pill: '限时立减',
tone: 'orange',
enabled: true,
pc: {
image_url: '/pc-poster.webp',
alt: 'PC 海报',
action: { type: 'route', target: '/listings', open_new_tab: false },
hotspots: [
{
id: 'hotspot-pc',
label: '去选号',
x: 10,
y: 20,
width: 20,
height: 30,
image_url: '/button.png',
hover_scale: 1.06,
action: { type: 'link', target: 'https://example.com/act', open_new_tab: true },
},
],
},
mobile: {
image_url: '/mobile-poster.webp',
action: { type: 'preview', target: '', open_new_tab: false },
},
},
],
'天域商行'
)
expect(slide?.pc.image_url).toBe('/pc-poster.webp')
expect(slide?.mobile.image_url).toBe('/mobile-poster.webp')
expect(slide?.eyebrow).toBe('本店活动')
expect(slide?.title).toBe('周末特惠')
expect(slide?.tone).toBe('orange')
expect(slide?.pc.hotspots[0]?.label).toBe('去选号')
expect(slide?.mobile.hotspots).toEqual([])
})
it('still accepts the legacy link field used by the old distribution admin', () => {
const [slide] = mapDistributionHomeBanners(
[{ image_url: '/poster.png', link: '/m/announcements' }],
'天域商行'
)
expect(slide?.pc.action).toEqual({
type: 'route',
target: '/m/announcements',
open_new_tab: false,
})
})
it('allows one side to stay empty when the two ends are configured separately', () => {
const [slide] = mapDistributionHomeBanners(
[{ title: '本店精选', pc: { image_url: '/pc.webp' }, mobile: {} }],
'天域商行'
)
expect(slide?.pc.image_url).toBe('/pc.webp')
expect(slide?.mobile.image_url).toBe('')
})
})
+70 -15
View File
@@ -10,49 +10,104 @@ import {
import {
createEmptyBannerAction,
createEmptyBannerVisual,
normalizeBannerVisual,
type BannerAction,
type BannerVisual,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
/**
* 分站轮播数据。
* 新结构复用主站的 pc/mobile 视觉对象;image_url/link_url/link 仅用于兼容历史扁平配置。
*/
export interface DistributionBanner {
image_url: string
image_url?: string
link_url?: string
/** 早期分销后台保存的字段名,与前端读取的 link_url 不一致,这里一并兼容。 */
link?: string
title?: string
eyebrow?: string
badge?: string
pill?: string
tone?: string
enabled?: boolean
pc?: Partial<BannerVisual>
mobile?: Partial<BannerVisual>
}
/** 分站首页轮播:有图时不再叠标题,避免整幅压暗。 */
const DISTRIBUTION_BANNER_TONES = ['blue', 'green', 'orange', 'gold']
/** 分站首页轮播:新结构按两端分别出图,旧结构继续沿用原来的海报/文字卡片表现。 */
export function mapDistributionHomeBanners(
banners: DistributionBanner[],
siteName: string
): HomeBannerSlide[] {
return banners.map(banner => {
const imageUrl = banner.image_url?.trim() || ''
const action = resolveDistributionBannerAction(banner.link_url)
const visual: BannerVisual = { ...createEmptyBannerVisual(), image_url: imageUrl, action }
const legacyImageUrl = text(banner.image_url)
const legacyAction = resolveDistributionBannerAction(banner.link_url || banner.link)
const structured = Boolean(banner.pc || banner.mobile)
const fallbackVisual: BannerVisual = {
...createEmptyBannerVisual(),
image_url: legacyImageUrl,
action: legacyAction,
}
const pc = structured
? normalizeBannerVisual(banner.pc, legacyImageUrl)
: { ...fallbackVisual, hotspots: [] }
const mobile = structured
? normalizeBannerVisual(banner.mobile, legacyImageUrl)
: { ...fallbackVisual, hotspots: [] }
const legacyImage = Boolean(legacyImageUrl)
const hasImage = Boolean(pc.image_url || mobile.image_url)
const copy = structured
? {
eyebrow: text(banner.eyebrow),
title: text(banner.title),
pill: text(banner.pill),
}
: {
eyebrow: legacyImage ? '' : siteName,
title: legacyImage ? '' : text(banner.title) || '精选游戏账号 · 随租随玩',
pill: legacyImage ? '' : '官方保障',
}
// 双端都没配图和文案时仍保留一张可读的文字卡片,避免前台出现空白轮播。
if (structured && !hasImage && !copy.eyebrow && !copy.title && !copy.pill) {
copy.eyebrow = siteName
copy.title = '精选游戏账号 · 随租随玩'
copy.pill = '官方保障'
}
return {
eyebrow: imageUrl ? '' : siteName,
title: imageUrl ? '' : banner.title?.trim() || '精选游戏账号 · 随租随玩',
badge: '本店精选',
pill: imageUrl ? '' : '官方保障',
tone: 'gold',
enabled: true,
// 分站装修只配一张图,两端共用,避免移动端丢图
pc: { ...visual, hotspots: [] },
mobile: { ...visual, hotspots: [] },
eyebrow: copy.eyebrow,
title: copy.title,
badge: text(banner.badge) || (structured ? '' : '本店精选'),
pill: copy.pill,
tone: structured ? normalizeBannerTone(banner.tone) : 'gold',
enabled: banner.enabled !== false,
pc,
mobile,
}
})
}
/** 分站轮播的 link_url:绝对地址走外链新窗口,站内路径走路由。 */
function resolveDistributionBannerAction(linkUrl?: string): BannerAction {
const target = linkUrl?.trim() || ''
const target = text(linkUrl)
if (!target) return createEmptyBannerAction()
if (/^https?:\/\//i.test(target)) return { type: 'link', target, open_new_tab: true }
if (target.startsWith('/')) return { type: 'route', target, open_new_tab: false }
return createEmptyBannerAction()
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeBannerTone(value: unknown): string {
const tone = text(value)
return DISTRIBUTION_BANNER_TONES.includes(tone) ? tone : 'gold'
}
/** 分站首页主标题:站名与主站联名。主站或站名已含主站名时保持原样。 */
export function formatDistributionCoBrandName(siteName: string, isDistribution: boolean): string {
const name = siteName.trim()