Files
hfb_sys/distribution-admin/src/homeBanner.ts
T

255 lines
8.0 KiB
TypeScript

/**
* 分销站点首页轮播配置。
* 与主站 mobile.home_banners 共用 pc / mobile 双端结构,历史扁平数据在读取时自动升级。
*/
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
}
export interface BannerHotspot {
id: string
label: string
x: number
y: number
width: number
height: number
image_url: string
hover_scale: number
action: BannerAction
}
export interface BannerVisual {
image_url: string
alt: string
action: BannerAction
hotspots: BannerHotspot[]
}
export interface DistributionBannerConfig {
eyebrow: string
title: string
badge: string
pill: string
tone: 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 ACTION_TYPES: BannerActionType[] = ['none', 'route', 'link', 'preview']
const BANNER_TONES = ['blue', 'green', 'orange', 'gold']
export const BANNER_HOTSPOT_DEFAULT_HOVER_SCALE = 1.06
const BANNER_HOTSPOT_MAX_HOVER_SCALE = 1.5
export function createEmptyBannerAction(): BannerAction {
return { type: 'none', target: '', open_new_tab: false }
}
export function createEmptyBannerVisual(): BannerVisual {
return { image_url: '', alt: '', action: createEmptyBannerAction(), hotspots: [] }
}
export function createBannerHotspot(partial: Partial<BannerHotspot> = {}): BannerHotspot {
return {
id: `hotspot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
label: '',
x: 6,
y: 20,
width: 24,
height: 24,
image_url: '',
hover_scale: BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
action: createEmptyBannerAction(),
...partial,
}
}
export function createEmptyDistributionBanner(): DistributionBannerConfig {
return {
eyebrow: '',
title: '',
badge: '本店精选',
pill: '',
tone: 'gold',
enabled: true,
pc: createEmptyBannerVisual(),
mobile: createEmptyBannerVisual(),
}
}
export function normalizeBannerAction(raw: unknown): BannerAction {
const source = isRecord(raw) ? raw : {}
const rawType = typeof source.type === 'string' ? source.type.trim() : ''
const type = ACTION_TYPES.find(item => item === rawType) ?? 'none'
const target = text(source.target)
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: text(raw.label),
x: clampPercent(raw.x, 100 - width),
y: clampPercent(raw.y, 100 - height),
width,
height,
image_url: text(raw.image_url),
hover_scale: normalizeHotspotHoverScale(raw.hover_scale),
action: normalizeBannerAction(raw.action),
}
}
export function normalizeBannerVisual(
raw: unknown,
legacy: { imageUrl?: string; action?: BannerAction } = {}
): BannerVisual {
const source = isRecord(raw) ? raw : {}
const imageUrl = text(source.image_url) || text(legacy.imageUrl)
const action = normalizeBannerAction(source.action)
const rawHotspots = Array.isArray(source.hotspots) ? source.hotspots : []
return {
image_url: imageUrl,
alt: text(source.alt),
action: action.type === 'none' && legacy.action ? legacy.action : action,
hotspots: rawHotspots
.map((item, index) => normalizeBannerHotspot(item, index))
.filter((item): item is BannerHotspot => item !== null),
}
}
/** 读取后台 JSON:新结构原样归一化,旧扁平结构自动升级为两端共图。 */
export function parseDistributionBanners(
raw: string | undefined,
siteName = ''
): DistributionBannerConfig[] {
const parsed = safeParseJSON(raw)
if (!Array.isArray(parsed)) return []
return parsed.map(item => normalizeDistributionBanner(item, siteName))
}
export function normalizeDistributionBanner(
raw: unknown,
siteName = ''
): DistributionBannerConfig {
const source = isRecord(raw) ? raw : {}
const legacyImageUrl = text(source.image_url)
const legacyAction = resolveLegacyBannerAction(source.link_url || source.link)
const structured = isRecord(source.pc) || isRecord(source.mobile)
const sharedLegacy = { imageUrl: legacyImageUrl, action: legacyAction }
const banner: DistributionBannerConfig = {
eyebrow: text(source.eyebrow),
title: text(source.title),
badge: text(source.badge),
pill: text(source.pill),
tone: normalizeBannerTone(source.tone),
enabled: source.enabled !== false,
pc: normalizeBannerVisual(source.pc, sharedLegacy),
mobile: normalizeBannerVisual(source.mobile, sharedLegacy),
}
if (!structured) {
const hasImage = Boolean(legacyImageUrl)
banner.eyebrow = hasImage ? '' : text(source.eyebrow) || siteName
banner.title = hasImage ? '' : text(source.title) || '精选游戏账号 · 随租随玩'
banner.badge = text(source.badge) || '本店精选'
banner.pill = hasImage ? '' : text(source.pill) || '官方保障'
banner.tone = 'gold'
} else if (!banner.badge) {
banner.badge = ''
}
return banner
}
/** 保存前过滤完全空白的卡片,序列化为与主站一致的 JSON 结构。 */
export function serializeDistributionBanners(banners: DistributionBannerConfig[]): string {
return JSON.stringify(banners.filter(hasBannerContent))
}
export function hasBannerContent(banner: DistributionBannerConfig): boolean {
return Boolean(
banner.eyebrow.trim() ||
banner.title.trim() ||
banner.pill.trim() ||
banner.pc.image_url.trim() ||
banner.mobile.image_url.trim() ||
banner.pc.hotspots.length ||
banner.mobile.hotspots.length
)
}
function resolveLegacyBannerAction(raw: unknown): BannerAction {
const target = text(raw)
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 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
}
function normalizeBannerTone(value: unknown): string {
const tone = text(value)
return BANNER_TONES.includes(tone) ? tone : 'gold'
}
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 text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function safeParseJSON(raw: string | undefined): unknown {
const value = (raw || '').trim()
if (!value) return []
try {
return JSON.parse(value)
} catch {
return []
}
}