diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 4de5ec4..4f3c86b 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -46,6 +46,7 @@ * - right-panel.js:处理右侧在线用户列表和用户名交互。 * - rooms.js:处理房间在线状态渲染和跳转 URL。 * - reward-modal.js:处理职务奖励金币弹窗入口。 + * - red-packet-panel.js:处理礼包红包发包、抢包、倒计时和广播监听。 * - message-queue.js:提供聊天消息分批渲染队列。 */ @@ -196,6 +197,14 @@ export { resolveRoomUrl, } from "./chat-room/rooms.js"; export { bindRewardModalControls, openRewardModal } from "./chat-room/reward-modal.js"; +export { + bindRedPacketPanelControls, + claimRedPacket, + closeRedPacketModal, + sendRedPacket, + showRedPacketModal, + updateRedPacketClaimsUI, +} from "./chat-room/red-packet-panel.js"; export { createMessageQueue } from "./chat-room/message-queue.js"; import { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; @@ -344,6 +353,14 @@ import { resolveRoomUrl, } from "./chat-room/rooms.js"; import { bindRewardModalControls, openRewardModal } from "./chat-room/reward-modal.js"; +import { + bindRedPacketPanelControls, + claimRedPacket, + closeRedPacketModal, + sendRedPacket, + showRedPacketModal, + updateRedPacketClaimsUI, +} from "./chat-room/red-packet-panel.js"; import { createMessageQueue } from "./chat-room/message-queue.js"; if (typeof window !== "undefined") { @@ -524,6 +541,12 @@ if (typeof window !== "undefined") { resolveRoomUrl, bindRewardModalControls, openRewardModal, + bindRedPacketPanelControls, + claimRedPacket, + closeRedPacketModal, + sendRedPacket, + showRedPacketModal, + updateRedPacketClaimsUI, createMessageQueue, }; @@ -598,6 +621,11 @@ if (typeof window !== "undefined") { window.switchVipTab = switchVipTab; window.switchBankTab = switchBankTab; window.toggleBankRankSort = toggleBankRankSort; + window.claimRedPacket = claimRedPacket; + window.closeRedPacketModal = closeRedPacketModal; + window.sendRedPacket = sendRedPacket; + window.showRedPacketModal = showRedPacketModal; + window.updateRedPacketClaimsUI = updateRedPacketClaimsUI; window.applyFontSize = applyFontSize; window.closeAvatarPicker = closeAvatarPicker; window.closeSettingsModal = closeSettingsModal; @@ -677,6 +705,7 @@ if (typeof window !== "undefined") { bindChatRightPanelControls(); bindRoomStatusControls(); bindRewardModalControls(); + bindRedPacketPanelControls(); bindMobileDrawerControls(); bindWelcomeMenuControls(); bindBlockMenuControls(); diff --git a/resources/js/chat-room/red-packet-panel.js b/resources/js/chat-room/red-packet-panel.js new file mode 100644 index 0000000..0d43261 --- /dev/null +++ b/resources/js/chat-room/red-packet-panel.js @@ -0,0 +1,679 @@ +// 礼包红包前端交互模块,负责发包、抢包、弹窗倒计时和广播监听。 + +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); +} diff --git a/resources/views/chat/partials/games/red-packet-panel.blade.php b/resources/views/chat/partials/games/red-packet-panel.blade.php index 98ad09a..56fb25a 100644 --- a/resources/views/chat/partials/games/red-packet-panel.blade.php +++ b/resources/views/chat/partials/games/red-packet-panel.blade.php @@ -270,525 +270,4 @@ - +{{-- 礼包红包前端交互脚本已迁移到 resources/js/chat-room/red-packet-panel.js --}}