Files
chatroom/resources/js/chat-room/holiday-modal.js
T
2026-04-25 14:47:07 +08:00

663 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 聊天室节日福利弹窗模块,负责福利广播、系统消息按钮、领取弹窗和状态同步。
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 ` <button type="button"
data-holiday-run-id="${escapeHtml(runId)}"
style="display:inline-flex; align-items:center; gap:4px; margin-left:8px; padding:3px 10px; border:none; border-radius:999px; background:linear-gradient(135deg,#f59e0b,#d97706); color:#fff; font-size:12px; font-weight:bold; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.22); vertical-align:middle;"
title="点击领取本轮节日福利">${escapeHtml(label)}</button>`;
}
/**
* 构建公屏节日福利系统消息。
*
* @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<void>}
*/
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<void>}
*/
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(
`🌟 <b>${username}</b> 领取了【${eventName}${roundText},获得 <b>${claimedAmount.toLocaleString()}</b> 金币!${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);
}