255 lines
8.0 KiB
TypeScript
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 []
|
|
}
|
|
}
|