重构(chat): 聊天室 Partials 第二阶段分类拆分及修复红包弹窗隐藏 Bug

- 完成对 scripts.blade.php 中非核心业务逻辑(钓鱼游戏、AI机器人、系统全局公告)的深度抽象隔离
- 修复抢红包逻辑中 setInterval 缺失时间参数(1000)引发浏览器前端主线程挂起的重度阻塞问题
- 修复 lottery-panel 组件结尾漏写 </div> 导致的连锁级渲染树崩溃(该崩溃导致红包节点被意外当作隐藏后代节点渲染,造成彻底不可见)
- 对相关模板规范代码结构,执行 Laravel Pint 格式化并提交
This commit is contained in:
2026-03-09 11:30:11 +08:00
parent 28d9f9ee96
commit bfb1a3bca4
24 changed files with 2806 additions and 2601 deletions
@@ -0,0 +1,688 @@
{{--
文件功能:礼包红包弹窗面板(HTML + CSS + 交互脚本)
包含:
1. 红包遮罩弹窗 DOM#red-packet-modal
2. 红包弹窗样式(CSS
3. 红包前端交互 JS(发包、抢包、倒计时、WebSocket 监听)
全局函数:
window.sendRedPacket() superlevel 发包(弹 chatBanner 选类型)
window.showRedPacketModal(...) 展示红包弹窗(收到 WebSocket 事件触发)
window.closeRedPacketModal() 关闭红包弹窗
window.claimRedPacket() 用户抢红包
window.updateRedPacketClaimsUI() 更新领取名单(WebSocket 广播后调用)
注:依赖 window.chatBannerchat-banner.blade.php)、window.chatDialog、window.chatToast。
scripts.blade.php 提取,与其他游戏面板(baccarat-panel、slot-machine 等)保持统一结构。
@author ChatRoom Laravel
@version 1.0.0
--}}
<style>
/* 红包弹窗遮罩 */
#red-packet-modal {
display: none;
position: fixed !important;
inset: 0 !important;
z-index: 10500 !important;
background: rgba(0, 0, 0, 0.6) !important;
justify-content: center !important;
align-items: center !important;
animation: rpFadeIn 0.25s ease;
}
@keyframes rpFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 红包卡片主体 */
#red-packet-card {
width: 300px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(220, 38, 38, 0.4);
animation: rpCardIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
}
@keyframes rpCardIn {
from {
transform: scale(0.7) translateY(40px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
/* 红包顶部区 */
#rp-header {
background: linear-gradient(160deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%);
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
#rp-header::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(255, 100, 0, 0.3) 0%, transparent 70%);
pointer-events: none;
}
.rp-emoji {
font-size: 52px;
display: block;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
animation: rpBounce 1.5s ease-in-out infinite;
}
@keyframes rpBounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.rp-sender {
color: #fde68a;
font-size: 13px;
margin-top: 8px;
font-weight: bold;
}
.rp-title {
color: #fff;
font-size: 18px;
font-weight: bold;
margin-top: 4px;
letter-spacing: 1px;
}
.rp-amount {
color: #fde68a;
font-size: 28px;
font-weight: bold;
margin-top: 6px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.rp-amount small {
font-size: 14px;
opacity: 0.85;
}
/* 红包底部区 */
#rp-body {
background: #fff8f0;
padding: 16px 20px;
}
.rp-info-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #92400e;
margin-bottom: 8px;
}
/* 倒计时条 */
#rp-timer-bar-wrap {
background: #fee2e2;
border-radius: 4px;
height: 6px;
overflow: hidden;
margin-bottom: 14px;
}
#rp-timer-bar {
height: 100%;
background: linear-gradient(90deg, #dc2626, #f97316);
border-radius: 4px;
transition: width 1s linear;
}
/* 领取按钮 */
#rp-claim-btn {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #dc2626, #ea580c);
color: #fff;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 10px;
cursor: pointer;
letter-spacing: 1px;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
transition: opacity .2s, transform .15s;
}
#rp-claim-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
#rp-claim-btn:active {
transform: translateY(0);
}
#rp-claim-btn:disabled {
background: #9ca3af;
box-shadow: none;
cursor: not-allowed;
transform: none;
}
/* 已领取名单 */
#rp-claims-list {
margin-top: 12px;
max-height: 100px;
overflow-y: auto;
border-top: 1px dashed #fca5a5;
padding-top: 8px;
}
.rp-claim-item {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #555;
padding: 2px 0;
}
.rp-claim-item span:last-child {
color: #dc2626;
font-weight: bold;
}
/* 关闭按钮 */
#rp-close-btn {
position: absolute;
top: 10px;
right: 12px;
color: rgba(255, 255, 255, 0.7);
font-size: 20px;
cursor: pointer;
line-height: 1;
}
#rp-close-btn:hover {
color: #fff;
}
/* 状态提示 */
#rp-status-msg {
font-size: 12px;
text-align: center;
margin-top: 8px;
min-height: 16px;
color: #16a34a;
font-weight: bold;
}
</style>
{{-- 红包弹窗 DOM --}}
<div id="red-packet-modal">
<div id="red-packet-card">
{{-- 顶部标题区 --}}
<div id="rp-header">
<span id="rp-close-btn" onclick="closeRedPacketModal()"></span>
<span class="rp-emoji">🧧</span>
<div class="rp-sender" id="rp-sender-name">xxx 的礼包</div>
<div class="rp-title">聊天室专属礼包</div>
<div class="rp-amount"><small>总计 </small><span id="rp-total-amount">888</span><small id="rp-type-label">
金币</small></div>
</div>
{{-- 底部操作区 --}}
<div id="rp-body">
<div class="rp-info-row">
<span>剩余份数:<b id="rp-remaining">10</b> / <span id="rp-total-count">10</span> </span>
<span>倒计时:<b id="rp-countdown">120</b>s</span>
</div>
<div id="rp-timer-bar-wrap">
<div id="rp-timer-bar" style="width:100%;"></div>
</div>
<button id="rp-claim-btn" onclick="claimRedPacket()">🧧 立即抢红包</button>
<div id="rp-status-msg"></div>
{{-- 领取名单 --}}
<div id="rp-claims-list" style="display:none;">
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
<div id="rp-claims-items"></div>
</div>
</div>
</div>
</div>
<script>
/**
* 礼包红包前端交互模块
*
* 功能:
* 1. sendRedPacket() superlevel 点击「礼包」按钮后确认发包
* 2. showRedPacketModal() 收到 RedPacketSent 事件后弹出红包卡片
* 3. claimRedPacket() 用户点击「立即抢红包」
* 4. closeRedPacketModal() 关闭红包弹窗
* 5. WebSocket 监听 监听 red-packet.sent 广播事件
*/
(function() {
'use strict';
// 当前红包状态
let _rpEnvelopeId = null; // 当前红包 ID
let _rpExpireAt = null; // 过期时间戳(ms
let _rpTotalSeconds = 120; // 总倒计时秒数
let _rpTimer = null; // 倒计时定时器
let _rpClaimed = false; // 本次会话是否已领取
let _rpType = 'gold'; // 当前红包类型(gold / exp
// ── 发包确认 ───────────────────────────────────────
/**
* superlevel 点击「礼包」按钮,弹出 chatBanner 三按钮选择类型后发包。
*/
window.sendRedPacket = function() {
window.chatBanner.show({
icon: '🧧',
title: '发出礼包',
name: '选择礼包类型',
body: '将发出 <b>888</b> 数量共 <b>10</b> 份的礼包,系统凭空发放,房间成员先到先得!',
gradient: ['#991b1b', '#dc2626', '#ea580c'],
titleColor: '#fde68a',
autoClose: 0,
buttons: [{
label: '💰 金币礼包',
color: '#d97706',
onClick(btn, close) {
close();
doSendRedPacket('gold');
},
},
{
label: '✨ 经验礼包',
color: '#7c3aed',
onClick(btn, close) {
close();
doSendRedPacket('exp');
},
},
{
label: '取消',
color: 'rgba(255,255,255,0.15)',
onClick(btn, close) {
close();
},
},
],
});
};
/**
* 实际发包请求(由 chatBanner 按钮回调触发)。
*
* @param {'gold'|'exp'} type 货币类型
*/
async function doSendRedPacket(type) {
const btn = document.getElementById('red-packet-btn');
if (btn) {
btn.disabled = true;
btn.textContent = '发送中…';
}
try {
const res = await fetch('/command/red-packet/send', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
type
}),
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
await window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
}
// 成功后 WebSocket 广播 RedPacketSent,前端自动弹出红包卡片
} catch (e) {
await window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
} finally {
setTimeout(() => {
if (btn) {
btn.disabled = false;
btn.innerHTML = '🧧 礼包';
}
}, 3000);
}
}
/**
* 展示红包弹窗,并启动倒计时。
*
* @param {number} envelopeId 红包 ID
* @param {string} senderUsername 发包人用户名
* @param {number} totalAmount 总数量
* @param {number} totalCount 总份数
* @param {number} expireSeconds 有效秒数
* @param {'gold'|'exp'} type 货币类型
*/
window.showRedPacketModal = function(envelopeId, senderUsername, totalAmount, totalCount, expireSeconds,
type) {
try {
console.log('showRedPacketModal 触发,当前状态:', {
envelopeId,
senderUsername,
totalAmount,
totalCount,
expireSeconds,
type,
oldId: _rpEnvelopeId
});
_rpEnvelopeId = envelopeId;
_rpClaimed = false;
_rpTotalSeconds = expireSeconds;
_rpExpireAt = Date.now() + expireSeconds * 1000;
_rpType = type || 'gold'; // 保存类型供 claimRedPacket 使用
// 根据类型调整配色和标签
const isExp = (type === 'exp');
const typeIcon = isExp ? '✨' : '💰';
const typeName = isExp ? '经验' : '金币';
const headerBg = isExp ?
'linear-gradient(160deg,#6d28d9 0%,#5b21b6 50%,#4c1d95 100%)' :
'linear-gradient(160deg,#dc2626 0%,#b91c1c 50%,#991b1b 100%)';
const claimBg = isExp ?
'linear-gradient(135deg,#7c3aed,#4f46e5)' :
'linear-gradient(135deg,#dc2626,#ea580c)';
const modalEl = document.getElementById('red-packet-modal');
if (!modalEl) {
alert('致命错误:红包视图容器 #red-packet-modal 找不到!');
return;
}
// 强制解除隐藏,赋予超高权限层级
modalEl.style.setProperty('display', 'flex', 'important');
modalEl.style.setProperty('z-index', '9999999', 'important');
modalEl.style.setProperty('opacity', '1', 'important');
modalEl.style.setProperty('visibility', 'visible', 'important');
// 应用配色
document.getElementById('rp-header').style.background = headerBg;
const claimBtn = document.getElementById('rp-claim-btn');
if (claimBtn) {
claimBtn.style.background = claimBg;
}
// 填入数据
document.getElementById('rp-sender-name').textContent = senderUsername + ' 的礼包';
document.getElementById('rp-total-amount').textContent = totalAmount;
document.getElementById('rp-total-count').textContent = totalCount;
document.getElementById('rp-remaining').textContent = totalCount;
document.getElementById('rp-countdown').textContent = expireSeconds;
document.getElementById('rp-timer-bar').style.width = '100%';
document.getElementById('rp-status-msg').textContent = '';
document.getElementById('rp-claims-list').style.display = 'none';
document.getElementById('rp-claims-items').innerHTML = '';
// 更新卡片标题信息
const emojiEl = modalEl.querySelector('.rp-emoji');
if (emojiEl) emojiEl.textContent = typeIcon;
const titleEl = modalEl.querySelector('.rp-title');
if (titleEl) titleEl.textContent = typeName + '礼包';
const typeLabel = document.getElementById('rp-type-label');
if (typeLabel) typeLabel.textContent = ' ' + typeName;
if (claimBtn) {
claimBtn.disabled = false;
claimBtn.textContent = typeIcon + ' 立即抢包';
}
} catch (err) {
console.error('showRedPacketModal 执行失败:', err);
alert('红包弹窗初始化异常: ' + err.message);
}
// 启动倒计时
clearInterval(_rpTimer);
_rpTimer = setInterval(() => {
const remaining = Math.max(0, Math.ceil((_rpExpireAt - Date.now()) / 1000));
document.getElementById('rp-countdown').textContent = remaining;
document.getElementById('rp-timer-bar').style.width =
(remaining / _rpTotalSeconds * 100) + '%';
if (remaining <= 0) {
clearInterval(_rpTimer);
document.getElementById('rp-claim-btn').disabled = true;
document.getElementById('rp-claim-btn').textContent = '礼包已过期';
document.getElementById('rp-status-msg').style.color = '#9ca3af';
document.getElementById('rp-status-msg').textContent = '红包已过期。';
}
}, 1000);
// 异步拉取服务端最新状态(实时刷新剩余份数)
fetch(`/red-packet/${envelopeId}/status`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
return;
}
// 更新剩余份数显示
document.getElementById('rp-remaining').textContent = data.remaining_count;
// 若已过期 → 关闭弹窗 + Toast 提示
if (data.is_expired || data.envelope_status === 'expired') {
clearInterval(_rpTimer);
closeRedPacketModal();
window.chatToast?.show({
title: '⏰ 礼包已过期',
message: '该礼包已超过有效期,无法领取。',
icon: '⏰',
color: '#9ca3af',
duration: 4000,
});
return;
}
// 若已抢完 → 关闭弹窗 + Toast 提示
if (data.remaining_count <= 0 || data.envelope_status === 'completed') {
clearInterval(_rpTimer);
closeRedPacketModal();
window.chatToast?.show({
title: '😅 手慢了!',
message: '礼包已被抢完,下次要快一点哦!',
icon: '🧧',
color: '#f59e0b',
duration: 4000,
});
return;
}
// 若本人已领取 → 关闭弹窗 + Toast 提示
if (data.has_claimed) {
clearInterval(_rpTimer);
closeRedPacketModal();
window.chatToast?.show({
title: '✅ 已领取',
message: '您已成功领取过本次礼包!',
icon: '🧧',
color: '#10b981',
duration: 4000,
});
}
})
.catch(() => {}); // 静默忽略网络错误,不影响弹窗展示
};
// ── 抢包/关闭逻辑 ─────────────────────────────────────
window.closeRedPacketModal = function() {
console.trace('closeRedPacketModal 被调用');
document.getElementById('red-packet-modal').style.display = 'none';
if (_rpTimer) clearInterval(_rpTimer);
};
// 点击遮罩关闭
document.getElementById('red-packet-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeRedPacketModal();
}
});
// ── 抢红包 ──────────────────────────────────────
/**
* 用户点击「立即抢红包」,调用后端 claim 接口。
*/
window.claimRedPacket = async function() {
if (!_rpEnvelopeId) {
return;
}
const btn = document.getElementById('rp-claim-btn');
btn.disabled = true;
btn.textContent = '抢包中…';
try {
const res = await fetch(`/red-packet/${_rpEnvelopeId}/claim`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext.roomId
}),
});
const data = await res.json();
const statusEl = document.getElementById('rp-status-msg');
const typeLabel = (_rpType === 'exp') ? '经验' : '金币';
if (res.ok && data.status === 'success') {
_rpClaimed = true;
btn.textContent = '🎉 已抢到!';
statusEl.style.color = '#16a34a';
statusEl.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}`;
// 弹出全局 Toast
window.chatToast.show({
title: '🧧 礼包到账',
message: `恭喜您抢到了礼包 <b>${data.amount}</b> ${typeLabel}`,
icon: '🧧',
color: (_rpType === 'exp') ? '#7c3aed' : '#dc2626',
duration: 8000,
});
// 3 秒后自动关闭弹窗
setTimeout(() => closeRedPacketModal(), 3000);
} else {
statusEl.style.color = '#dc2626';
statusEl.textContent = data.message || '抢包失败';
// 若已领过或已抢完则禁用按钮,否则解除禁用以重试
if (data.message && (data.message.includes('已经领过') || data.message.includes('已被抢完') ||
data.message.includes('已抢完'))) {
btn.textContent = '已参与';
} else {
btn.disabled = false;
btn.textContent = '🧧 立即抢红包';
}
}
} catch (e) {
document.getElementById('rp-status-msg').textContent = '网络异常,请重试';
document.getElementById('rp-status-msg').style.color = '#dc2626';
btn.disabled = false;
btn.textContent = '🧧 立即抢红包';
}
};
// ── 更新领取名单(被 WS 触发调用)───────────────
/**
* 收到「系统传音」中的领取播报时,同步更新弹窗内的名单与剩余数。
*
* @param {string} username 领取者用户名
* @param {number} amount 领取金额
* @param {number} remaining 剩余份数
*/
window.updateRedPacketClaimsUI = function(username, amount, remaining) {
const remainingEl = document.getElementById('rp-remaining');
if (remainingEl) {
remainingEl.textContent = remaining;
}
const listEl = document.getElementById('rp-claims-list');
const itemsEl = document.getElementById('rp-claims-items');
if (!listEl || !itemsEl) {
return;
}
listEl.style.display = 'block';
const item = document.createElement('div');
item.className = 'rp-claim-item';
item.innerHTML = `<span>${username}</span><span>+${amount} 金币</span>`;
itemsEl.prepend(item);
// 若已全部领完,更新按钮状态
if (remaining <= 0) {
const btn = document.getElementById('rp-claim-btn');
if (btn && !_rpClaimed) {
btn.disabled = true;
btn.textContent = '礼包已被抢完!';
}
clearInterval(_rpTimer);
// 3 秒后自动关闭
setTimeout(() => closeRedPacketModal(), 3000);
}
};
// ── WebSocket 监听 red-packet.sent ───────────────
/**
* 等待 Echo 就绪后注册 red-packet.sent 事件监听,
* 每次收到新红包时弹出红包卡片弹窗。
*/
function setupRedPacketListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRedPacketListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen('.red-packet.sent', (e) => {
// 收到红包事件,弹出卡片(type 决定金币/经验配色)
showRedPacketModal(
e.envelope_id,
e.sender_username,
e.total_amount,
e.total_count,
e.expire_seconds,
e.type || 'gold',
);
});
console.log('RedPacketSent 监听器已注册');
}
document.addEventListener('DOMContentLoaded', setupRedPacketListener);
})(); // end IIFE
</script>