import { computed, ref, watch, type ComputedRef } from 'vue'; import AppApi from '@/service/app'; import { useConfigStore } from '@/stores/config'; /** 浮窗倒计时显示阶段 */ export type HongbaoRainPhase = 'countdown' | 'active'; export interface HongbaoRainStatus { /** 浮窗上展示的文字(未开始:HH:mm:ss;进行中:立即参与) */ text: string; phase: HongbaoRainPhase; } /** 服务端单查空数据错误码:-1010 时浮窗已下线或暂无下一场,需移除 */ const CODE_EMPTY_DATA = '-1010'; /** * 把 banner.url 上的 query 解析出来,兼容只在 # 后带参数的写法 */ const getBannerUrlSearchParams = (url: string) => { const queryIndex = url.indexOf('?'); if (queryIndex !== -1) { const hashIndex = url.indexOf('#', queryIndex + 1); const query = hashIndex === -1 ? url.slice(queryIndex + 1) : url.slice(queryIndex + 1, hashIndex); return new URLSearchParams(query); } const hashIndex = url.indexOf('#'); if (hashIndex !== -1 && url.slice(hashIndex + 1).includes('=')) { return new URLSearchParams(url.slice(hashIndex + 1)); } return new URLSearchParams(); }; /** * 红包雨外链:url 上带 type=hongbaoRain(及活动 id)即视为红包雨入口; * 不要求 url 的 id 与 banner.id 字符串完全一致 */ export const isHongbaoRainBanner = (banner: Types.App.BannerJump | undefined | null) => { if (!banner?.id || !banner?.url) return false; try { const type = getBannerUrlSearchParams(banner.url).get('type'); if (type?.toLowerCase() === 'hongbaorain') return true; return /(?:^|[?&#])type=hongbaoRain(?:&|$|[#])/i.test(banner.url); } catch { return /(?:^|[?&#])type=hongbaoRain(?:&|$|[#])/i.test(banner.url); } }; /** HH:mm:ss 格式化剩余时间 */ const formatCountdown = (remainMs: number) => { const totalSec = Math.max(0, Math.floor(remainMs / 1000)); const hour = Math.floor(totalSec / 3600); const minute = Math.floor((totalSec % 3600) / 60); const second = totalSec % 60; return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`; }; const isAbortError = (error: unknown) => { if (!error || typeof error !== 'object') return false; const err = error as { code?: string; name?: string }; return err.code === 'ERR_CANCELED' || err.name === 'AbortError' || err.name === 'CanceledError'; }; /** * 浮窗「红包雨场次倒计时」状态管理: * - 维护各浮窗的倒计时文案 / 进行中文案 * - 每秒刷新一次;场次结束时按 banner.id 单查刷新,处理下一场或下线 * * 抽离原因:包含定时器、AbortController、跨场次副作用,且与展示组件其它业务无耦合, * 需要独立的启动/停止时机,符合"多个副作用块需抽离"的规范。 */ export const useHongbaoRainCountdown = (bannerList: ComputedRef) => { const configStore = useConfigStore(); const statusMap = ref>({}); /** 已处理过"场次结束"事件的 banner,避免每秒重复单查 */ const endedHandledIds = ref>(new Set()); const refreshingBannerId = ref(''); let countdownTimer: number | null = null; let isActive = false; let refreshAbort: AbortController | null = null; const hongbaoRainBannerList = computed(() => bannerList.value.filter(isHongbaoRainBanner)); const getBannerStatus = (bannerId: string) => statusMap.value[bannerId]; const setStatus = (bannerId: string, status: HongbaoRainStatus) => { statusMap.value = { ...statusMap.value, [bannerId]: status }; }; const cancelRefresh = () => { refreshAbort?.abort(); refreshAbort = null; }; /** * 用单查结果替换列表中的 banner; * 传 null 表示该 banner 已下线,从列表中移除 */ const replaceBannerById = (bannerId: string, banner: Types.App.BannerJump | null) => { const oldList = configStore.appConfig?.bannerJumpList || []; const nextList = oldList.filter((item) => item?.id !== bannerId); if (banner) { nextList.push(banner); } configStore.appConfig.bannerJumpList = nextList; }; /** * 场次结束 → 单查最新场次; * - 成功:用新的 startAt/endAt 接续倒计时 * - -1010:移除该 banner */ const refreshBanner = async (bannerId: string) => { if (!isActive || !bannerId || refreshingBannerId.value === bannerId) return; cancelRefresh(); const abortController = new AbortController(); refreshAbort = abortController; refreshingBannerId.value = bannerId; try { const latestBanner = await AppApi.queryBannerById(bannerId, abortController.signal); if (!isActive) return; if (latestBanner?.id) { replaceBannerById(bannerId, latestBanner); endedHandledIds.value.delete(bannerId); updateStatus(); } } catch (error: unknown) { if (isAbortError(error) || !isActive) return; const errorCode = String((error as Error)?.message || ''); if (errorCode === CODE_EMPTY_DATA) { replaceBannerById(bannerId, null); } } finally { if (refreshAbort === abortController) { refreshAbort = null; } refreshingBannerId.value = ''; } }; /** 单条 banner 状态更新:未开始 / 进行中 / 已结束 */ const updateStatusForBanner = (banner: Types.App.BannerJump) => { const bannerId = banner.id; if (!isHongbaoRainBanner(banner)) return; // 非红包雨倒计时类型(兜底兼容):直接展示"立即参与" if (Number(banner.countdownType) !== 1) { setStatus(bannerId, { text: '立即参与', phase: 'active' }); return; } const startAt = new Date(banner.startAt).getTime(); const endAt = new Date(banner.endAt).getTime(); if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) { setStatus(bannerId, { text: '', phase: 'countdown' }); return; } const now = Date.now(); if (now < startAt) { setStatus(bannerId, { text: formatCountdown(startAt - now), phase: 'countdown', }); endedHandledIds.value.delete(bannerId); return; } if (now < endAt) { setStatus(bannerId, { text: '立即参与', phase: 'active' }); endedHandledIds.value.delete(bannerId); return; } // 场次已结束:先清空文案;同一 id 只在边界触发一次单查 setStatus(bannerId, { text: '', phase: 'countdown' }); if (!endedHandledIds.value.has(bannerId)) { endedHandledIds.value = new Set(endedHandledIds.value).add(bannerId); refreshBanner(bannerId); } }; const updateStatus = () => { if (!isActive) return; const list = hongbaoRainBannerList.value; if (!list.length) { statusMap.value = {}; endedHandledIds.value = new Set(); return; } // 清理已不在列表中的状态/已处理标记,避免内存堆积 const activeIds = new Set(list.map((item) => item.id)); statusMap.value = Object.fromEntries( Object.entries(statusMap.value).filter(([id]) => activeIds.has(id)), ); endedHandledIds.value = new Set([...endedHandledIds.value].filter((id) => activeIds.has(id))); list.forEach((banner) => updateStatusForBanner(banner)); }; const startCountdown = () => { if (countdownTimer) { clearInterval(countdownTimer); } isActive = true; updateStatus(); countdownTimer = window.setInterval(() => { updateStatus(); }, 1000); }; const stopCountdown = () => { isActive = false; if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } cancelRefresh(); }; /** 配置侧的 bannerJumpList 变更时(如其它入口刷新配置)同步更新状态 */ watch( () => [hongbaoRainBannerList.value, configStore.appConfig?.bannerJumpList] as const, () => { if (!isActive) return; updateStatus(); }, { deep: true }, ); return { statusMap, getBannerStatus, isHongbaoRainBanner, startCountdown, stopCountdown, }; };