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