Files
chatroom/resources/js/chat-room/red-packet-panel.js
T
2026-04-25 18:22:27 +08:00

680 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.
// 礼包红包前端交互模块,负责发包、抢包、弹窗倒计时和广播监听。
let redPacketEnvelopeId = null;
let redPacketExpireAt = null;
let redPacketTotalSeconds = 120;
let redPacketTimer = null;
let redPacketClaimed = false;
let redPacketType = "gold";
let redPacketEventsBound = false;
/**
* 读取 CSRF Token,给红包接口请求统一使用。
*
* @returns {string}
*/
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 读取指定 DOM 节点。
*
* @param {string} id 节点 ID
* @returns {HTMLElement|null}
*/
function byId(id) {
return document.getElementById(id);
}
/**
* 重置礼包按钮状态。
*
* @param {HTMLButtonElement|null} button 礼包按钮
* @returns {void}
*/
function resetRedPacketButton(button) {
if (!button) {
return;
}
button.disabled = false;
button.innerHTML = "🧧 礼包";
}
/**
* 读取当前职务的礼包红包默认配置。
*
* @returns {Promise<{amount:number,count:number,expire_seconds:number}>}
*/
async function fetchRedPacketConfig() {
const response = await fetch("/command/red-packet/config", {
headers: {
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
},
});
const data = await response.json();
if (!response.ok || data.status !== "success") {
throw new Error(data.message || "读取礼包配置失败");
}
return data;
}
/**
* 发起实际发包请求。
*
* @param {"gold"|"exp"} type 礼包类型
* @returns {Promise<void>}
*/
async function doSendRedPacket(type) {
const button = byId("red-packet-btn");
if (button) {
button.disabled = true;
button.textContent = "发送中…";
}
try {
const response = await fetch("/command/red-packet/send", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrfToken(),
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
type,
}),
});
const data = await response.json();
if (!response.ok || data.status !== "success") {
await window.chatDialog.alert(data.message || "发送失败", "操作失败", "#cc4444");
}
} catch (error) {
await window.chatDialog.alert(`发送失败:${error.message}`, "操作失败", "#cc4444");
} finally {
setTimeout(() => resetRedPacketButton(button), 3000);
}
}
/**
* superlevel 点击“礼包”按钮后弹出类型选择。
*
* @returns {Promise<void>}
*/
export async function sendRedPacket() {
const button = byId("red-packet-btn");
if (button) {
button.disabled = true;
button.textContent = "读取中…";
}
try {
const config = await fetchRedPacketConfig();
const amountText = Number(config.amount || 0).toLocaleString("zh-CN");
const countText = Number(config.count || 0).toLocaleString("zh-CN");
window.chatBanner.show({
icon: "🧧",
title: "发出礼包",
name: "选择礼包类型",
body: `将发出 ${amountText} 数量共 ${countText} 份的礼包,系统凭空发放,房间成员先到先得!`,
gradient: ["#991b1b", "#dc2626", "#ea580c"],
titleColor: "#fde68a",
autoClose: 0,
buttons: [
{
label: "💰 金币礼包",
color: "#d97706",
onClick(buttonEl, close) {
close();
doSendRedPacket("gold");
},
},
{
label: "✨ 经验礼包",
color: "#7c3aed",
onClick(buttonEl, close) {
close();
doSendRedPacket("exp");
},
},
{
label: "取消",
color: "rgba(255,255,255,0.15)",
onClick(buttonEl, close) {
close();
},
},
],
});
} catch (error) {
await window.chatDialog.alert(error.message || "读取礼包配置失败", "操作失败", "#cc4444");
} finally {
resetRedPacketButton(button);
}
}
/**
* 从触发按钮读取红包发送时间,兼容历史系统消息中的 data-sent-at。
*
* @returns {number|null}
*/
function readSentAtFromCurrentEvent() {
if (!window.event?.currentTarget?.dataset?.sentAt) {
return null;
}
const sentAt = Number.parseInt(window.event.currentTarget.dataset.sentAt, 10);
return Number.isNaN(sentAt) ? null : sentAt;
}
/**
* 预查红包状态,避免已过期或抢完的红包弹窗闪现。
*
* @param {number|string} envelopeId 红包 ID
* @param {number} totalCount 总份数
* @returns {Promise<{allowed:boolean, remainingCount:number, totalCount:number}>}
*/
async function preflightRedPacketStatus(envelopeId, totalCount) {
try {
const response = await fetch(`/red-packet/${envelopeId}/status`, {
headers: {
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
},
});
const data = await response.json();
if (data.status !== "success") {
return {
allowed: true,
remainingCount: totalCount,
totalCount,
};
}
if (data.is_expired || data.envelope_status === "expired") {
window.chatToast?.show({
title: "⏰ 礼包已过期",
message: "该红包已过期,无法领取。",
icon: "⏰",
color: "#9ca3af",
duration: 4000,
});
return {
allowed: false,
remainingCount: 0,
totalCount,
};
}
if (data.remaining_count <= 0 || data.envelope_status === "completed") {
window.chatToast?.show({
title: "😅 手慢了!",
message: "红包已被抢完,下次要快一点哦!",
icon: "🧧",
color: "#f59e0b",
duration: 4000,
});
return {
allowed: false,
remainingCount: 0,
totalCount,
};
}
if (data.has_claimed) {
window.chatToast?.show({
title: "✅ 已领取",
message: "您已成功领取过本次礼包!",
icon: "🧧",
color: "#10b981",
duration: 4000,
});
return {
allowed: false,
remainingCount: data.remaining_count,
totalCount: data.total_count || totalCount,
};
}
return {
allowed: true,
remainingCount: data.remaining_count,
totalCount: data.total_count || totalCount,
};
} catch (error) {
console.error("红包状态前置预查失败:", error);
return {
allowed: true,
remainingCount: totalCount,
totalCount,
};
}
}
/**
* 应用红包弹窗的类型配色。
*
* @param {"gold"|"exp"} type 礼包类型
* @returns {{typeIcon:string,typeName:string}}
*/
function applyRedPacketTypeStyle(type) {
const isExp = type === "exp";
const typeIcon = isExp ? "✨" : "💰";
const typeName = isExp ? "经验" : "金币";
const headerBackground = isExp
? "linear-gradient(160deg,#6d28d9 0%,#5b21b6 50%,#4c1d95 100%)"
: "linear-gradient(160deg,#dc2626 0%,#b91c1c 50%,#991b1b 100%)";
const claimBackground = isExp
? "linear-gradient(135deg,#7c3aed,#4f46e5)"
: "linear-gradient(135deg,#dc2626,#ea580c)";
byId("rp-header").style.background = headerBackground;
const claimButton = byId("rp-claim-btn");
if (claimButton) {
claimButton.style.background = claimBackground;
}
return {
typeIcon,
typeName,
};
}
/**
* 填充红包弹窗内容。
*
* @param {Record<string, any>} payload 弹窗数据
* @returns {void}
*/
function renderRedPacketModal(payload) {
const modal = byId("red-packet-modal");
const claimButton = byId("rp-claim-btn");
const styleConfig = applyRedPacketTypeStyle(payload.type);
modal.style.setProperty("display", "flex", "important");
modal.style.setProperty("z-index", "9999999", "important");
modal.style.setProperty("opacity", "1", "important");
modal.style.setProperty("visibility", "visible", "important");
byId("rp-sender-name").textContent = `${payload.senderUsername} 的礼包`;
byId("rp-total-amount").textContent = payload.totalAmount;
byId("rp-total-count").textContent = payload.totalCount;
byId("rp-remaining").textContent = payload.remainingCount;
byId("rp-countdown").textContent = payload.expireSeconds;
byId("rp-timer-bar").style.width = "100%";
byId("rp-status-msg").textContent = "";
byId("rp-claims-list").style.display = "none";
byId("rp-claims-items").textContent = "";
const emoji = modal.querySelector(".rp-emoji");
if (emoji) {
emoji.textContent = styleConfig.typeIcon;
}
const title = modal.querySelector(".rp-title");
if (title) {
title.textContent = `${styleConfig.typeName}礼包`;
}
const typeLabel = byId("rp-type-label");
if (typeLabel) {
typeLabel.textContent = ` ${styleConfig.typeName}`;
}
if (claimButton) {
claimButton.disabled = false;
claimButton.textContent = `${styleConfig.typeIcon} 立即抢包`;
}
}
/**
* 启动红包倒计时。
*
* @returns {void}
*/
function startRedPacketTimer() {
clearInterval(redPacketTimer);
redPacketTimer = setInterval(() => {
const remaining = Math.max(0, Math.ceil((redPacketExpireAt - Date.now()) / 1000));
byId("rp-countdown").textContent = remaining;
byId("rp-timer-bar").style.width = `${(remaining / redPacketTotalSeconds) * 100}%`;
if (remaining <= 0) {
clearInterval(redPacketTimer);
const claimButton = byId("rp-claim-btn");
claimButton.disabled = true;
claimButton.textContent = "礼包已过期";
const status = byId("rp-status-msg");
status.style.color = "#9ca3af";
status.textContent = "红包已过期,即将关闭…";
setTimeout(() => closeRedPacketModal(), 3000);
}
}, 1000);
}
/**
* 展示红包弹窗,并启动倒计时。
*
* @param {number|string} envelopeId 红包 ID
* @param {string} senderUsername 发包人
* @param {number|string} totalAmount 总数量
* @param {number} totalCount 总份数
* @param {number} expireSeconds 有效秒数
* @param {"gold"|"exp"} type 礼包类型
* @returns {Promise<void>}
*/
export async function showRedPacketModal(envelopeId, senderUsername, totalAmount, totalCount, expireSeconds, type = "gold") {
try {
const sentAt = readSentAtFromCurrentEvent();
let calculatedExpireAt = Date.now() + expireSeconds * 1000;
if (sentAt && sentAt > 0) {
calculatedExpireAt = (sentAt + expireSeconds) * 1000;
}
if (sentAt && Date.now() >= calculatedExpireAt) {
window.chatToast?.show({
title: "⏰ 礼包已过期",
message: "该红包已过期,无法领取。",
icon: "⏰",
color: "#9ca3af",
duration: 4000,
});
return;
}
const preflight = await preflightRedPacketStatus(envelopeId, totalCount);
if (!preflight.allowed) {
return;
}
redPacketEnvelopeId = envelopeId;
redPacketClaimed = false;
redPacketType = type || "gold";
redPacketExpireAt = calculatedExpireAt;
redPacketTotalSeconds = expireSeconds;
const modal = byId("red-packet-modal");
if (!modal) {
window.chatDialog?.alert("致命错误:红包视图容器 #red-packet-modal 找不到!", "系统错误", "#cc4444");
return;
}
renderRedPacketModal({
senderUsername,
totalAmount,
totalCount: preflight.totalCount,
remainingCount: preflight.remainingCount,
expireSeconds,
type: redPacketType,
});
startRedPacketTimer();
} catch (error) {
console.error("showRedPacketModal 执行失败:", error);
window.chatDialog?.alert(`红包弹窗初始化异常: ${error.message}`, "系统错误", "#cc4444");
}
}
/**
* 关闭红包弹窗并停止倒计时。
*
* @returns {void}
*/
export function closeRedPacketModal() {
const modal = byId("red-packet-modal");
if (modal) {
modal.style.display = "none";
}
if (redPacketTimer) {
clearInterval(redPacketTimer);
}
}
/**
* 用户点击“立即抢红包”。
*
* @returns {Promise<void>}
*/
export async function claimRedPacket() {
if (!redPacketEnvelopeId) {
return;
}
const button = byId("rp-claim-btn");
button.disabled = true;
button.textContent = "抢包中…";
try {
const response = await fetch(`/red-packet/${redPacketEnvelopeId}/claim`, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrfToken(),
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
}),
});
const data = await response.json();
handleClaimResponse(response, data, button);
} catch (error) {
const status = byId("rp-status-msg");
status.textContent = "网络异常,请重试";
status.style.color = "#dc2626";
button.disabled = false;
button.textContent = "🧧 立即抢红包";
}
}
/**
* 处理抢红包接口响应。
*
* @param {Response} response Fetch 响应对象
* @param {Record<string, any>} data 接口数据
* @param {HTMLButtonElement} button 抢包按钮
* @returns {void}
*/
function handleClaimResponse(response, data, button) {
const status = byId("rp-status-msg");
const typeLabel = redPacketType === "exp" ? "经验" : "金币";
if (response.ok && data.status === "success") {
redPacketClaimed = true;
button.textContent = "🎉 已抢到!";
status.style.color = "#16a34a";
status.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}`;
const remaining = byId("rp-remaining");
if (remaining && typeof data.remaining_count === "number") {
remaining.textContent = data.remaining_count;
}
window.chatToast.show({
title: "🧧 礼包到账",
message: `恭喜您抢到了礼包 ${data.amount} ${typeLabel}`,
icon: "🧧",
color: redPacketType === "exp" ? "#7c3aed" : "#dc2626",
duration: 8000,
});
setTimeout(() => closeRedPacketModal(), 3000);
return;
}
status.style.color = "#dc2626";
status.textContent = data.message || "抢包失败";
updateClaimButtonAfterFailure(button, data.message || "");
}
/**
* 根据失败原因更新抢包按钮状态。
*
* @param {HTMLButtonElement} button 抢包按钮
* @param {string} message 失败文案
* @returns {void}
*/
function updateClaimButtonAfterFailure(button, message) {
const shouldAutoClose = message.includes("已过期")
|| message.includes("已被抢完")
|| message.includes("已抢完")
|| message.includes("红包已抢完或已过期");
if (shouldAutoClose) {
button.textContent = "礼包已结束";
setTimeout(() => closeRedPacketModal(), 3000);
return;
}
if (message.includes("已经领过")) {
button.textContent = "已参与";
return;
}
button.disabled = false;
button.textContent = "🧧 立即抢红包";
}
/**
* 收到领取广播后,同步弹窗内领取名单与剩余数。
*
* @param {string} username 领取者用户名
* @param {number|string} amount 领取数量
* @param {number} remaining 剩余份数
* @param {"gold"|"exp"} type 礼包类型
* @returns {void}
*/
export function updateRedPacketClaimsUI(username, amount, remaining, type = redPacketType) {
const remainingElement = byId("rp-remaining");
if (remainingElement) {
remainingElement.textContent = remaining;
}
const list = byId("rp-claims-list");
const items = byId("rp-claims-items");
if (!list || !items) {
return;
}
list.style.display = "block";
const item = document.createElement("div");
const name = document.createElement("span");
const value = document.createElement("span");
const typeLabel = type === "exp" ? "经验" : "金币";
item.className = "rp-claim-item";
name.textContent = username;
value.textContent = `+${amount} ${typeLabel}`;
item.append(name, value);
items.prepend(item);
if (remaining <= 0) {
const button = byId("rp-claim-btn");
if (button && !redPacketClaimed) {
button.disabled = true;
button.textContent = "礼包已被抢完!";
}
clearInterval(redPacketTimer);
setTimeout(() => closeRedPacketModal(), 3000);
}
}
/**
* 注册红包 Echo 监听。
*
* @returns {void}
*/
function setupRedPacketListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRedPacketListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen(".red-packet.sent", (event) => {
showRedPacketModal(
event.envelope_id,
event.sender_username,
event.total_amount,
event.total_count,
event.expire_seconds,
event.type || "gold",
);
})
.listen(".red-packet.claimed", (event) => {
if (Number(event.envelope_id) !== Number(redPacketEnvelopeId)) {
return;
}
updateRedPacketClaimsUI(
event.claimer_username,
event.amount,
event.remaining_count,
event.type || redPacketType,
);
});
}
/**
* 挂载红包全局入口并绑定静态按钮事件。
*
* @returns {void}
*/
export function bindRedPacketPanelControls() {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
window.sendRedPacket = sendRedPacket;
window.showRedPacketModal = showRedPacketModal;
window.closeRedPacketModal = closeRedPacketModal;
window.claimRedPacket = claimRedPacket;
window.updateRedPacketClaimsUI = updateRedPacketClaimsUI;
if (redPacketEventsBound) {
return;
}
redPacketEventsBound = true;
byId("red-packet-modal")?.addEventListener("click", (event) => {
if (event.target === event.currentTarget) {
closeRedPacketModal();
}
});
byId("rp-close-btn")?.addEventListener("click", (event) => {
event.preventDefault();
closeRedPacketModal();
});
byId("rp-claim-btn")?.addEventListener("click", (event) => {
event.preventDefault();
claimRedPacket();
});
document.addEventListener("DOMContentLoaded", setupRedPacketListener);
}