680 lines
20 KiB
JavaScript
680 lines
20 KiB
JavaScript
// 礼包红包前端交互模块,负责发包、抢包、弹窗倒计时和广播监听。
|
||
|
||
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);
|
||
}
|