/** * 分销站点首页轮播配置。 * 与主站 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 = { 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 { 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 { 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 [] } }