// 聊天室节日福利弹窗模块,负责福利广播、系统消息按钮、领取弹窗和状态同步。 import { escapeHtml } from "./html.js"; let holidayModalEventsBound = false; /** * 返回第一个非空值。 * * @param {...unknown} values * @returns {unknown|null} */ function firstHolidayDefined(...values) { for (const value of values) { if (value !== undefined && value !== null && value !== "") { return value; } } return null; } /** * 将后端字段转换为数字。 * * @param {unknown} value * @param {number} fallback * @returns {number} */ function toHolidayNumber(value, fallback = 0) { if (value === undefined || value === null || value === "") { return fallback; } const parsedValue = Number(value); return Number.isFinite(parsedValue) ? parsedValue : fallback; } /** * 解析日期字符串。 * * @param {unknown} value * @returns {Date|null} */ function parseHolidayDate(value) { if (!value) { return null; } const parsedDate = new Date(value); return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; } /** * 格式化福利时间。 * * @param {unknown} value * @returns {string} */ function formatHolidayDate(value) { const parsedDate = parseHolidayDate(value); if (!parsedDate) { return ""; } return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(parsedDate); } /** * 格式化剩余有效期。 * * @param {Date|null} expiresAt * @returns {string} */ function formatHolidayRemaining(expiresAt) { if (!expiresAt) { return "以后端状态为准"; } const remainingMilliseconds = expiresAt.getTime() - Date.now(); if (remainingMilliseconds <= 0) { return "已过期"; } const totalMinutes = Math.ceil(remainingMilliseconds / 60000); if (totalMinutes < 60) { return `${totalMinutes} 分钟`; } if (totalMinutes < 24 * 60) { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`; } const days = Math.floor(totalMinutes / (24 * 60)); const hours = Math.floor((totalMinutes % (24 * 60)) / 60); return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`; } /** * 生成福利轮次标签。 * * @param {object} detail * @returns {string} */ function buildHolidayRoundLabel(detail) { const explicitLabel = firstHolidayDefined(detail.round_label, detail.batch_label, detail.run_label); if (explicitLabel) { return String(explicitLabel); } const roundNumber = toHolidayNumber(detail.round_no, 0); if (detail.repeat_type === "yearly") { return roundNumber > 0 ? `年度第 ${roundNumber} 轮福利` : "年度福利批次"; } if (roundNumber > 0) { return `第 ${roundNumber} 轮福利`; } return detail.scheduled_for ? "本轮定时福利" : "当前福利批次"; } /** * 生成弹窗描述文案。 * * @param {object} detail * @returns {string} */ function buildHolidayDescription(detail) { if (detail.description) { return String(detail.description); } const amountText = detail.distribute_type === "fixed" && detail.fixed_amount !== null ? `每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币` : "随机金额发放"; const quotaText = detail.max_claimants > 0 ? `前 ${detail.max_claimants} 名在线用户可领取` : "在线用户均可领取"; return `本轮福利已开启,${amountText},${quotaText}。`; } /** * 兼容后端广播字段归一化入口。 * * @param {object|null|undefined} detail * @returns {object} */ function normalizeHolidayPayload(detail) { if (typeof window.normalizeHolidayBroadcastEvent === "function") { return window.normalizeHolidayBroadcastEvent(detail); } return detail ?? {}; } /** * 构建公屏系统消息里的领取按钮。 * * @param {number|string|null} runId * @param {string} label * @returns {string} */ export function buildHolidayClaimActionButton(runId, label = "🎁 立即领取") { if (!runId) { return ""; } return ` `; } /** * 构建公屏节日福利系统消息。 * * @param {object} detail * @returns {string} */ export function buildHolidaySystemMessage(detail) { const quotaText = detail.max_claimants > 0 ? `前 ${detail.max_claimants} 名在线用户可领取` : "在线用户均可领取"; const amountText = detail.distribute_type === "fixed" && detail.fixed_amount !== null ? `每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币` : "随机金额发放"; const scheduleText = detail.scheduled_for ? `发放时间 ${formatHolidayDate(detail.scheduled_for)}` : null; const roundText = detail.round_label ? ` ${detail.round_label}` : ""; const runId = firstHolidayDefined(detail.run_id, detail.id); return [ `🎊 【${escapeHtml(detail.name || "节日福利")}】${escapeHtml(roundText)}开始啦!`, `总奖池 💰${toHolidayNumber(detail.total_amount, 0).toLocaleString()} 金币`, escapeHtml(amountText), escapeHtml(quotaText), escapeHtml(scheduleText || ""), ].filter(Boolean).join(",") + buildHolidayClaimActionButton(runId); } /** * 创建节日福利 Alpine 弹窗组件。 * * @returns {object} */ export function holidayEventModal() { return { show: false, loadingStatus: false, claiming: false, claimable: true, claimed: false, expiresTimer: null, autoCloseTimer: null, runId: null, legacyEventId: null, eventName: "", eventDesc: "", roundLabel: "", totalAmount: 0, maxClaimants: 0, distributeType: "random", fixedAmount: null, scheduledFor: null, scheduledForText: "", expiresAt: null, expiresIn: "", claimedAmount: 0, statusHint: "", /** * 返回领取按钮文字。 * * @returns {string} */ claimButtonText() { if (this.loadingStatus) { return "同步领取状态中…"; } if (this.claiming) { return "领取中…"; } if (!this.claimable) { return "当前不可领取"; } return "🎁 立即领取福利"; }, /** * 打开弹窗并填充活动数据。 * * @param {object} detail * @returns {void} */ open(detail) { const holidayDetail = normalizeHolidayPayload(detail); this.runId = holidayDetail.run_id ?? null; this.legacyEventId = holidayDetail.event_id ?? null; this.eventName = holidayDetail.name ?? "节日福利"; this.eventDesc = buildHolidayDescription(holidayDetail); this.roundLabel = buildHolidayRoundLabel(holidayDetail); this.totalAmount = toHolidayNumber(holidayDetail.total_amount, 0); this.maxClaimants = toHolidayNumber(holidayDetail.max_claimants, 0); this.distributeType = holidayDetail.distribute_type ?? "random"; this.fixedAmount = firstHolidayDefined(holidayDetail.fixed_amount, null); this.scheduledFor = parseHolidayDate(holidayDetail.scheduled_for); this.scheduledForText = formatHolidayDate(holidayDetail.scheduled_for); this.expiresAt = parseHolidayDate(holidayDetail.expires_at); this.claimable = true; this.claimed = false; this.loadingStatus = false; this.claiming = false; this.claimedAmount = 0; this.stopAutoCloseTimer(); this.statusHint = holidayDetail.run_id ? "当前奖励按本轮福利批次发放,请在有效期内领取。" : "兼容旧活动通道,等待主线广播升级"; this.updateExpiresIn(); this.startExpiresTimer(); this.show = true; void this.syncStatus(); }, /** * 关闭弹窗并停止计时器。 * * @returns {void} */ close() { this.show = false; this.stopExpiresTimer(); this.stopAutoCloseTimer(); }, /** * 启动有效期刷新计时器。 * * @returns {void} */ startExpiresTimer() { this.stopExpiresTimer(); this.expiresTimer = window.setInterval(() => this.updateExpiresIn(), 30000); }, /** * 停止有效期刷新计时器。 * * @returns {void} */ stopExpiresTimer() { if (this.expiresTimer) { window.clearInterval(this.expiresTimer); this.expiresTimer = null; } }, /** * 启动领取成功后的自动关闭。 * * @returns {void} */ startAutoCloseTimer() { this.stopAutoCloseTimer(); this.autoCloseTimer = window.setTimeout(() => this.close(), 3000); }, /** * 停止自动关闭计时器。 * * @returns {void} */ stopAutoCloseTimer() { if (this.autoCloseTimer) { window.clearTimeout(this.autoCloseTimer); this.autoCloseTimer = null; } }, /** * 更新有效期展示。 * * @returns {void} */ updateExpiresIn() { this.expiresIn = formatHolidayRemaining(this.expiresAt); }, /** * 构建状态接口候选地址。 * * @returns {string[]} */ buildStatusUrls() { const urls = []; if (this.runId) { urls.push(`/holiday/runs/${encodeURIComponent(this.runId)}/status`); } if (this.legacyEventId) { urls.push(`/holiday/${encodeURIComponent(this.legacyEventId)}/status`); } return urls; }, /** * 构建领取接口候选地址。 * * @returns {string[]} */ buildClaimUrls() { const urls = []; if (this.runId) { urls.push(`/holiday/runs/${encodeURIComponent(this.runId)}/claim`); } if (this.legacyEventId) { urls.push(`/holiday/${encodeURIComponent(this.legacyEventId)}/claim`); } return urls; }, /** * 同步当前福利批次状态。 * * @returns {Promise} */ async syncStatus() { const statusUrls = this.buildStatusUrls(); if (statusUrls.length === 0) { this.claimable = false; this.statusHint = "缺少 run_id,无法查询当前福利状态。"; return; } this.loadingStatus = true; try { for (const url of statusUrls) { const response = await fetch(url, { headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest", }, }); if (response.status === 404 || response.status === 405) { continue; } const data = await response.json(); this.applyStatus(data); return; } this.statusHint = "状态接口尚未切换完成,可直接尝试领取。"; } catch (error) { this.statusHint = "状态同步失败,可直接尝试领取。"; } finally { this.loadingStatus = false; } }, /** * 应用后端返回的状态快照。 * * @param {object} data * @returns {void} */ applyStatus(data) { if (data.expires_at) { this.expiresAt = parseHolidayDate(data.expires_at); this.updateExpiresIn(); } const statusValue = firstHolidayDefined(data.status, data.claim_status); const alreadyClaimed = Boolean(data.claimed ?? data.has_claimed ?? false) || ["claimed", "received", "paid"].includes(statusValue); const amount = toHolidayNumber(firstHolidayDefined(data.claimed_amount, data.amount), 0); if (alreadyClaimed) { this.claimed = true; this.claimable = false; this.claimedAmount = amount; this.statusHint = data.message ?? "本轮福利已领取。"; return; } if (data.claimable === false || data.can_claim === false) { this.claimable = false; this.statusHint = data.message ?? "当前不在可领取名单或领取窗口已关闭。"; return; } this.claimable = true; this.statusHint = data.message ?? this.statusHint; }, /** * 发起领取请求。 * * @returns {Promise} */ async doClaim() { if (this.claiming || this.claimed || !this.claimable) { return; } const claimUrls = this.buildClaimUrls(); if (claimUrls.length === 0) { window.chatDialog?.alert?.("缺少福利批次标识,暂时无法领取。", "提示", "#f59e0b"); return; } this.claiming = true; try { for (const url of claimUrls) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-TOKEN": document.querySelector("meta[name=csrf-token]")?.content || "", }, }); if (response.status === 404 || response.status === 405) { continue; } const data = await response.json(); const claimedAmount = toHolidayNumber(firstHolidayDefined(data.claimed_amount, data.amount), 0); const alreadyClaimed = Boolean(data.claimed ?? data.has_claimed ?? false) || data.message?.includes("已领取"); if (data.ok || alreadyClaimed) { this.claimed = true; this.claimable = false; this.claimedAmount = claimedAmount; this.statusHint = `${data.message ?? "本轮福利已入账。"} 3 秒后自动关闭。`; this.appendClaimMessage(claimedAmount); this.startAutoCloseTimer(); if (window.__chatUser && data.balance !== undefined) { window.__chatUser.jjb = data.balance; } return; } this.statusHint = data.message ?? "领取失败,请稍后重试。"; window.chatDialog?.alert?.(this.statusHint, "提示", "#f59e0b"); if (data.message?.includes("已结束") || data.message?.includes("过期")) { this.claimable = false; this.close(); } else { void this.syncStatus(); } return; } window.chatDialog?.alert?.("领取接口尚未切换完成,请稍后再试。", "提示", "#f59e0b"); } catch (error) { window.chatDialog?.alert?.("网络异常,请稍后重试。", "错误", "#cc4444"); } finally { this.claiming = false; } }, /** * 向公屏追加领取成功消息。 * * @param {number} claimedAmount * @returns {void} */ appendClaimMessage(claimedAmount) { if (typeof window.appendSystemMessage !== "function") { return; } const username = escapeHtml(window.chatContext?.username ?? "当前用户"); const eventName = escapeHtml(this.eventName); const roundText = this.roundLabel ? `【${escapeHtml(this.roundLabel)}】` : ""; window.appendSystemMessage( `🌟 ${username} 领取了【${eventName}】${roundText},获得 ${claimedAmount.toLocaleString()} 金币!${buildHolidayClaimActionButton(this.runId)}`, ); }, }; } /** * 从公屏系统消息中打开已缓存的节日福利批次。 * * @param {string|number} runId 福利批次 ID * @returns {void} */ export function openHolidayRunFromSystemMessage(runId) { const normalizedRunId = String(runId || ""); const detail = window.__holidayRuns?.[normalizedRunId]; const modal = document.getElementById("holiday-event-modal"); if (!normalizedRunId || !modal || typeof window.Alpine?.$data !== "function") { return; } if (!detail) { window.chatDialog?.alert?.("当前福利批次信息未缓存,请等待下一轮广播或刷新页面后重试。", "提示", "#f59e0b"); return; } window.Alpine.$data(modal)?.open?.(detail); } /** * 处理节日福利广播事件。 * * @param {CustomEvent} event * @returns {void} */ function handleHolidayStarted(event) { const detail = normalizeHolidayPayload(event.detail); if (detail.run_id !== undefined && detail.run_id !== null) { window.__holidayRuns[String(detail.run_id)] = detail; } if (typeof window.appendSystemMessage === "function") { window.appendSystemMessage(buildHolidaySystemMessage({ ...detail, round_label: buildHolidayRoundLabel(detail), })); } const modal = document.getElementById("holiday-event-modal"); if (modal && typeof window.Alpine?.$data === "function") { window.Alpine.$data(modal)?.open?.(detail); } } /** * 绑定节日福利弹窗全局入口、系统消息按钮和广播事件。 * * @returns {void} */ export function bindHolidayModalControls() { if (typeof window === "undefined") { return; } window.__holidayRuns = window.__holidayRuns || {}; window.holidayEventModal = holidayEventModal; window.buildHolidayClaimActionButton = buildHolidayClaimActionButton; window.buildHolidaySystemMessage = buildHolidaySystemMessage; window.openHolidayRunFromSystemMessage = openHolidayRunFromSystemMessage; if (holidayModalEventsBound || typeof document === "undefined") { return; } holidayModalEventsBound = true; document.addEventListener("click", (event) => { if (!(event.target instanceof Element)) { return; } const claimButton = event.target.closest("[data-holiday-run-id]"); if (!claimButton) { return; } event.preventDefault(); openHolidayRunFromSystemMessage(claimButton.getAttribute("data-holiday-run-id") || ""); }); window.addEventListener("chat:holiday.started", handleHolidayStarted); }