237 lines
8.0 KiB
TypeScript
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,
|
|
};
|
|
};
|