Files
hgmj_h5/src/components/HomeAdv/hooks/useHongbaoRainCountdown.ts
T
谢宇宁 eada71e211 [Human] chore(project): 初始化导入 91dj H5 客户端项目代码
从本地工程首次入库;.npmrc(含私有 npm 源 token)与 tsbuildinfo 已加入 .gitignore,不随仓库提交。
2026-10-08 14:22:35 +07:00

237 lines
8.0 KiB
TypeScript

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<Types.App.BannerJump[]>) => {
const configStore = useConfigStore();
const statusMap = ref<Record<string, HongbaoRainStatus>>({});
/** 已处理过"场次结束"事件的 banner,避免每秒重复单查 */
const endedHandledIds = ref<Set<string>>(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,
};
};