重构(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

View File

@@ -10,6 +10,7 @@
* 接入 UserCurrencyService 记录所有货币变动流水。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -40,7 +41,7 @@ class RedPacketController extends Controller
private const TOTAL_COUNT = 10;
/** 礼包有效期(秒) */
private const EXPIRE_SECONDS = 120;
private const EXPIRE_SECONDS = 300;
/**
* 构造函数:注入依赖服务
@@ -62,12 +63,12 @@ class RedPacketController extends Controller
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:gold,exp',
'type' => 'required|in:gold,exp',
]);
$user = Auth::user();
$user = Auth::user();
$roomId = (int) $request->input('room_id');
$type = $request->input('type'); // 'gold' 或 'exp'
$type = $request->input('type'); // 'gold' 或 'exp'
// 权限校验:仅 superlevel 可发礼包
$superLevel = (int) Sysparam::getValue('superlevel', '100');
@@ -92,8 +93,8 @@ class RedPacketController extends Controller
// 货币展示文案
$typeLabel = $type === 'exp' ? '经验' : '金币';
$typeIcon = $type === 'exp' ? '✨' : '💰';
$btnBg = $type === 'exp'
$typeIcon = $type === 'exp' ? '✨' : '💰';
$btnBg = $type === 'exp'
? 'linear-gradient(135deg,#7c3aed,#4f46e5)'
: 'linear-gradient(135deg,#dc2626,#ea580c)';
@@ -101,16 +102,16 @@ class RedPacketController extends Controller
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
// 创建红包主记录(凭空发出,不扣发包人货币)
$envelope = RedPacketEnvelope::create([
'sender_id' => $user->id,
'sender_id' => $user->id,
'sender_username' => $user->username,
'room_id' => $roomId,
'type' => $type,
'total_amount' => self::TOTAL_AMOUNT,
'total_count' => self::TOTAL_COUNT,
'claimed_count' => 0,
'claimed_amount' => 0,
'status' => 'active',
'expires_at' => now()->addSeconds(self::EXPIRE_SECONDS),
'room_id' => $roomId,
'type' => $type,
'total_amount' => self::TOTAL_AMOUNT,
'total_count' => self::TOTAL_COUNT,
'claimed_count' => 0,
'claimed_amount' => 0,
'status' => 'active',
'expires_at' => now()->addSeconds(self::EXPIRE_SECONDS),
]);
// 将拆分好的数量序列存入 RedisListLPOP 抢红包)
@@ -137,15 +138,15 @@ class RedPacketController extends Controller
.'box-shadow:0 2px 6px rgba(0,0,0,0.3);">'.$typeIcon.' 立即抢包</button>';
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
'is_secret' => false,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
'is_secret' => false,
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
@@ -153,18 +154,18 @@ class RedPacketController extends Controller
// 广播红包事件(触发前端弹出红包卡片)
broadcast(new RedPacketSent(
roomId: $roomId,
envelopeId: $envelope->id,
roomId: $roomId,
envelopeId: $envelope->id,
senderUsername: $user->username,
totalAmount: self::TOTAL_AMOUNT,
totalCount: self::TOTAL_COUNT,
expireSeconds: self::EXPIRE_SECONDS,
type: $type,
totalAmount: self::TOTAL_AMOUNT,
totalCount: self::TOTAL_COUNT,
expireSeconds: self::EXPIRE_SECONDS,
type: $type,
));
return response()->json([
'status' => 'success',
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT."",
'status' => 'success',
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
]);
}
@@ -182,21 +183,26 @@ class RedPacketController extends Controller
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
}
$user = Auth::user();
$isExpired = $envelope->expires_at->isPast();
$user = Auth::user();
$isExpired = $envelope->expires_at->isPast();
$remainingCount = $envelope->remainingCount();
$hasClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
$hasClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
->where('user_id', $user->id)
->exists();
// 若已过期但 status 尚未同步,顺手更新为 expired
if ($isExpired && $envelope->status === 'active') {
$envelope->update(['status' => 'expired']);
}
return response()->json([
'status' => 'success',
'status' => 'success',
'remaining_count' => $remainingCount,
'total_count' => $envelope->total_count,
'envelope_status' => $envelope->status,
'is_expired' => $isExpired,
'has_claimed' => $hasClaimed,
'type' => $envelope->type ?? 'gold',
'total_count' => $envelope->total_count,
'envelope_status' => $isExpired ? 'expired' : $envelope->status,
'is_expired' => $isExpired,
'has_claimed' => $hasClaimed,
'type' => $envelope->type ?? 'gold',
]);
}
@@ -207,8 +213,8 @@ class RedPacketController extends Controller
* 重复领取通过 unique 约束保障幂等性。
* 按红包 type 字段决定入账金币还是经验。
*
* @param Request $request 需包含 room_id
* @param int $envelopeId 红包 ID
* @param Request $request 需包含 room_id
* @param int $envelopeId 红包 ID
*/
public function claim(Request $request, int $envelopeId): JsonResponse
{
@@ -216,7 +222,7 @@ class RedPacketController extends Controller
'room_id' => 'required|integer',
]);
$user = Auth::user();
$user = Auth::user();
$roomId = (int) $request->input('room_id');
// 加载红包记录
@@ -240,11 +246,11 @@ class RedPacketController extends Controller
// 从 Redis 原子 POP 一份数量
$redisKey = "red_packet:{$envelopeId}:amounts";
$amount = \Illuminate\Support\Facades\Redis::lpop($redisKey);
$amount = \Illuminate\Support\Facades\Redis::lpop($redisKey);
if ($amount === null || $amount === false) {
return response()->json(['status' => 'error', 'message' => '礼包已被抢完!'], 422);
}
$amount = (int) $amount;
$amount = (int) $amount;
// 兼容旧记录type 字段可能为 null
$envelopeType = $envelope->type ?? 'gold';
@@ -254,10 +260,10 @@ class RedPacketController extends Controller
// 写领取记录unique 约束保障不重复)
RedPacketClaim::create([
'envelope_id' => $envelope->id,
'user_id' => $user->id,
'username' => $user->username,
'amount' => $amount,
'claimed_at' => now(),
'user_id' => $user->id,
'username' => $user->username,
'amount' => $amount,
'claimed_at' => now(),
]);
// 更新红包统计
@@ -302,30 +308,30 @@ class RedPacketController extends Controller
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
// 在聊天室发送领取播报(所有人可见)
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
$claimedMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
'is_secret' => false,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
'is_secret' => false,
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $claimedMsg);
broadcast(new MessageSent($roomId, $claimedMsg));
SaveMessageJob::dispatch($claimedMsg);
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
$balanceNow = $user->fresh()->$balanceField;
$balanceNow = $user->fresh()->$balanceField;
return response()->json([
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}{$balanceNow}",
]);
}
@@ -338,18 +344,18 @@ class RedPacketController extends Controller
*
* @param int $total 总数量
* @param int $count 份数
* @return int[] 每份数量数组
* @return int[] 每份数量数组
*/
private function splitAmount(int $total, int $count): array
{
$amounts = [];
$amounts = [];
$remaining = $total;
for ($i = 1; $i < $count; $i++) {
$leftCount = $count - $i;
$max = min((int) floor($remaining / $leftCount * 2), $remaining - $leftCount);
$max = max(1, $max);
$amount = random_int(1, $max);
$max = min((int) floor($remaining / $leftCount * 2), $remaining - $leftCount);
$max = max(1, $max);
$amount = random_int(1, $max);
$amounts[] = $amount;
$remaining -= $amount;
}

View File

@@ -84,10 +84,11 @@ class UserController extends Controller
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
if ($operator && $operator->user_level >= $levelBanIp) {
$data['last_ip'] = $targetUser->last_ip;
$data['login_ip'] = $targetUser->login_ip; // 假设表中存在 login_ip 记录本次IP若无则使用 last_ip 退化
// last_ip 在每次登录时更新,即为用户最近一次登录的 IP本次IP
$data['login_ip'] = $targetUser->last_ip;
// 解析归属地:使用 ip2region 离线库,直接返回原生中文(省|市|ISP
$ipToLookup = $targetUser->login_ip ?: $targetUser->last_ip;
$ipToLookup = $targetUser->last_ip;
if ($ipToLookup) {
try {
// 不传路径,使用 zoujingli/ip2region 包自带的内置数据库

View File

@@ -94,8 +94,8 @@
{{-- ═══════════ 左侧主区域 ═══════════ --}}
<div class="chat-left">
{{-- 顶部标题栏 + 公告滚动条(独立文件维护) --}}
@include('chat.partials.header')
{{-- 顶部标题栏 + 公告滚动条(layout/ 子目录维护) --}}
@include('chat.partials.layout.header')
{{-- 消息窗格(双窗格,默认只显示 say1 --}}
<div class="message-panes" id="message-panes">
@@ -117,40 +117,43 @@
</div>
</div>
{{-- 底部输入工具栏(独立文件维护) --}}
@include('chat.partials.input-bar')
{{-- 底部输入工具栏(layout/ 子目录维护) --}}
@include('chat.partials.layout.input-bar')
</div>
{{-- ═══════════ 竖向工具条(独立文件维护) ═══════════ --}}
@include('chat.partials.toolbar')
{{-- ═══════════ 竖向工具条(layout/ 子目录维护) ═══════════ --}}
@include('chat.partials.layout.toolbar')
{{-- ═══════════ 右侧用户面板(独立文件维护) ═══════════ --}}
@include('chat.partials.right-panel')
{{-- ═══════════ 右侧用户面板(layout/ 子目录维护) ═══════════ --}}
@include('chat.partials.layout.right-panel')
</div>
{{-- ═══════════ 全局自定义弹窗(替代原生 alert/confirm全页面可用 ═══════════ --}}
{{-- ═══════════ 全局 UI 公共组件 ═══════════ --}}
{{-- 自定义弹窗(替代原生 alert/confirm/prompt全页面可用 --}}
@include('chat.partials.global-dialog')
{{-- Toast 轻提示 --}}
@include('chat.partials.toast-notification')
{{-- ═══════════ 聊天室交互脚本(独立文件维护) ═══════════ --}}
{{-- 大卡片通知(任命公告、好友通知、礼包选择等) --}}
@include('chat.partials.chat-banner')
{{-- ═══════════ 聊天室交互脚本(用户操作、好友通知等) ═══════════ --}}
@include('chat.partials.user-actions')
{{-- ═══════════ 婚姻系统弹窗组件 ═══════════ --}}
{{-- ═══════════ 活动与系统弹窗 ═══════════ --}}
{{-- 婚姻系统弹窗 --}}
@include('chat.partials.marriage-modals')
{{-- ═══════════ 节日福利弹窗组件 ═══════════ --}}
{{-- 节日福利弹窗 --}}
@include('chat.partials.holiday-modal')
{{-- ═══════════ 百家乐游戏面板 ═══════════ --}}
@include('chat.partials.baccarat-panel')
{{-- ═══════════ 老虎机游戏面板 ═══════════ --}}
@include('chat.partials.slot-machine')
{{-- ═══════════ 神秘箱子游戏面板 ═══════════ --}}
@include('chat.partials.mystery-box')
{{-- ═══════════ 赛马竞猜游戏面板 ═══════════ --}}
@include('chat.partials.horse-race-panel')
{{-- ═══════════ 神秘占卜游戏面板 ═══════════ --}}
@include('chat.partials.fortune-panel')
{{-- ═══════════ 双色球彩票面板 ═══════════ --}}
@include('chat.partials.lottery-panel')
{{-- ═══════════ 娱乐游戏大厅弹窗 ═══════════ --}}
@include('chat.partials.game-hall')
{{-- ═══════════ 游戏面板partials/games/ 子目录,各自独立,包含 CSS + HTML + JS ═══════════ --}}
@include('chat.partials.games.baccarat-panel')
@include('chat.partials.games.slot-machine')
@include('chat.partials.games.mystery-box')
@include('chat.partials.games.horse-race-panel')
@include('chat.partials.games.fortune-panel')
@include('chat.partials.games.lottery-panel')
@include('chat.partials.games.red-packet-panel')
@include('chat.partials.games.fishing-panel')
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
<script src="/js/effects/effect-sounds.js"></script>
@@ -162,6 +165,9 @@
@include('chat.partials.scripts')
{{-- 辅助与全局事件组件 --}}
@include('chat.partials.ai-chatbot')
@include('chat.partials.system-events')
{{-- 页面初始加载时,渲染自带的历史记录(解决入场欢迎语错过断网的问题) --}}
@if (!empty($historyMessages))
<script>

View File

@@ -0,0 +1,89 @@
<script>
// ── AI 聊天机器人 ──────────────────────────────────
let chatBotSending = false;
/**
* 发送消息给 AI 机器人
* 先在包厢窗口显示用户消息,再调用 API 获取回复
*/
async function sendToChatBot(content) {
if (chatBotSending) {
window.chatDialog.alert('AI 正在思考中,请稍候...', '提示', '#336699');
return;
}
chatBotSending = true;
// 显示"思考中"提示
// 延迟显示"思考中",让广播消息先到达
const thinkDiv = document.createElement('div');
thinkDiv.className = 'msg-line';
thinkDiv.innerHTML = '<span style="color: #16a34a;">🤖 <b>AI小班长</b> 正在思考中...</span>';
setTimeout(() => {
container2.appendChild(thinkDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}, 500);
try {
const res = await fetch(window.chatContext.chatBotUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
message: content,
room_id: window.chatContext.roomId
})
});
const data = await res.json();
// 移除"思考中"提示(消息已通过广播显示)
thinkDiv.remove();
if (!res.ok || data.status !== 'success') {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML =
`<span style="color: #dc2626;">🤖【AI小班长】${data.message || '回复失败,请稍后重试'}</span>`;
container.appendChild(errDiv);
}
} catch (e) {
thinkDiv.remove();
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML = '<span style="color: #dc2626;">🤖【AI小班长】网络连接错误请稍后重试</span>';
container.appendChild(errDiv);
}
chatBotSending = false;
scrollToBottom();
}
/**
* 清除与 AI 小助手的对话上下文
*/
async function clearChatBotContext() {
try {
const res = await fetch(window.chatContext.chatBotClearUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
const data = await res.json();
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML = '<span style="color: #16a34a;">🤖【系统】' + (data.message || '对话已重置') + '</span>';
container2.appendChild(sysDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
window.chatDialog.alert('清除失败:' + e.message, '操作失败', '#cc4444');
}
}
</script>

View File

@@ -0,0 +1,178 @@
{{--
文件功能全局大卡片通知组件chatBanner
提供全局 JS API
window.chatBanner.show(opts) 显示居中大卡片
window.chatBanner.close(id?) 关闭指定卡片
opts 参数:
id: string (可选) ID 会替换旧弹窗,防止重叠
icon: string Emoji 图标(如 '🎉💚🎉'
title: string 小标题(如 '好友通知'
name: string 大名字行(可留空)
body: string 主内容(支持 HTML
sub: string 副内容(小字)
gradient: string[] 渐变颜色数组3个颜色
titleColor: string 小标题颜色
autoClose: number 自动关闭 ms0=不关闭,默认 5000
buttons: Array<{
label: string
color: string
onClick(btn, close): Function
}>
scripts.blade.php 拆分,确保页面全局组件独立维护。
@author ChatRoom Laravel
@version 1.0.0
--}}
<script>
/**
* 全局大卡片通知公共组件。
* 对标 window.chatDialog window.chatToast 的设计风格,
* 用于展示任命公告、好友通知、红包选择等需要居中展示的大卡片。
*/
window.chatBanner = (function() {
/** 注入入场/退场动画(全局只注入一次) */
function ensureKeyframes() {
if (document.getElementById('appoint-keyframes')) {
return;
}
const style = document.createElement('style');
style.id = 'appoint-keyframes';
style.textContent = `
@keyframes appoint-pop {
0% { opacity: 0; transform: translate(-50%,-50%) scale(0.5); }
70% { transform: translate(-50%,-50%) scale(1.05); }
100% { opacity: 1; transform: translate(-50%,-50%) scale(1); }
}
@keyframes appoint-fade-out {
from { opacity: 1; }
to { opacity: 0; transform: translate(-50%,-50%) scale(0.9); }
}
`;
document.head.appendChild(style);
}
/**
* 显示大卡片通知。
*
* @param {Object} opts 选项(见上方注释)
*/
function show(opts = {}) {
ensureKeyframes();
// 大卡片弹出时播放叮咚通知音
if (window.chatSound) {
window.chatSound.ding();
}
const id = opts.id || 'chat-banner-default';
const gradient = (opts.gradient || ['#4f46e5', '#7c3aed', '#db2777']).join(', ');
const titleColor = opts.titleColor || '#fde68a';
const autoClose = opts.autoClose ?? 5000;
// 移除同 ID 的旧弹窗,避免重叠
const old = document.getElementById(id);
if (old) {
old.remove();
}
// 构建按钮 HTML
const hasButtons = opts.buttons && opts.buttons.length > 0;
let buttonsHtml = '';
if (hasButtons) {
buttonsHtml = '<div style="margin-top:18px; display:flex; gap:10px; justify-content:center;">';
opts.buttons.forEach((btn, idx) => {
buttonsHtml += `<button data-banner-btn="${idx}"
style="background:${btn.color || '#10b981'}; color:#fff; border:none; border-radius:8px;
padding:8px 20px; font-size:13px; font-weight:bold; cursor:pointer;
box-shadow:0 4px 12px rgba(0,0,0,0.25);">
${btn.label || '确定'}
</button>`;
});
buttonsHtml += '</div>';
}
const banner = document.createElement('div');
banner.id = id;
banner.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
z-index: 99999; text-align: center;
animation: appoint-pop 0.5s cubic-bezier(0.175,0.885,0.32,1.275);
${hasButtons ? 'pointer-events: auto;' : 'pointer-events: none;'}
`;
banner.innerHTML = `
<div style="background: linear-gradient(135deg, ${gradient});
border-radius: 20px; padding: 28px 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);
min-width: 260px;">
${opts.icon ? `<div style="font-size:40px; margin-bottom:8px;">${opts.icon}</div>` : ''}
${opts.title ? `<div style="color:${titleColor}; font-size:13px; font-weight:bold; letter-spacing:3px; margin-bottom:12px;">
══ ${opts.title} ══
</div>` : ''}
${opts.name ? `<div style="color:white; font-size:22px; font-weight:900; text-shadow:0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(opts.name)}
</div>` : ''}
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${opts.body}</div>` : ''}
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${opts.sub}</div>` : ''}
${buttonsHtml}
<div style="color:rgba(255,255,255,0.35); font-size:11px; margin-top:14px;">
${new Date().toLocaleTimeString('zh-CN')}
</div>
</div>
`;
document.body.appendChild(banner);
// 绑定按钮点击事件
if (hasButtons) {
const closeFn = () => {
banner.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => banner.remove(), 500);
};
opts.buttons.forEach((btn, idx) => {
const el = banner.querySelector(`[data-banner-btn="${idx}"]`);
if (el && btn.onClick) {
el.addEventListener('click', () => btn.onClick(el, closeFn));
} else if (el) {
el.addEventListener('click', closeFn);
}
});
}
// 自动关闭
if (autoClose > 0) {
setTimeout(() => {
if (!document.getElementById(id)) {
return;
}
banner.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => banner.remove(), 500);
}, autoClose);
}
}
/**
* 关闭指定 ID banner。
*
* @param {string} id banner DOM ID
*/
function close(id) {
const el = document.getElementById(id || 'chat-banner-default');
if (!el) {
return;
}
el.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => el.remove(), 500);
}
return {
show,
close
};
})();
</script>

View File

@@ -1,438 +0,0 @@
{{--
文件功能:娱乐游戏大厅弹窗组件
点击工具栏「娱乐」按钮后弹出,展示所有已开启的游戏:
- 百家乐:当前场次状态 + 倒计时 + 直接参与按钮
- 老虎机:今日限额余量 + 直接打开按钮
- 神秘箱子:已投放数量 + 直接打开按钮
- 赛马竞猜:当前场次状态 + 参与按钮
- 神秘占卜:今日占卜次数 + 直接打开按钮
- 钓鱼:状态 + 打开按钮
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ─── 服务端注入各游戏开关状态(避免前端额外请求)─── --}}
@php
$gameEnabled = [
'baccarat' => \App\Models\GameConfig::isEnabled('baccarat'),
'slot_machine' => \App\Models\GameConfig::isEnabled('slot_machine'),
'mystery_box' => \App\Models\GameConfig::isEnabled('mystery_box'),
'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'),
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
];
@endphp
<script>
/** 后台游戏开关状态Blade 服务端注入1分钟缓存 */
window.GAME_ENABLED = @json($gameEnabled);
</script>
{{-- ═══════════ 游戏大厅弹窗遮罩 ═══════════ --}}
<div id="game-hall-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
z-index:9998; justify-content:center; align-items:center;">
<div id="game-hall-inner"
style="width:680px; max-width:96vw; max-height:88vh; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); display:flex; flex-direction:column;
background:#fff; font-family:'Microsoft YaHei',SimSun,sans-serif;">
{{-- ─── 标题栏(与商店弹窗同风格)─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;
padding:10px 16px; display:flex; align-items:center; gap:10px; flex-shrink:0;">
<div style="font-size:14px; font-weight:bold; flex:1;">🎮 娱乐大厅</div>
<div
style="font-size:12px; color:#d0e8ff; display:flex; align-items:center; gap:3px;
background:rgba(0,0,0,.2); padding:2px 8px; border-radius:10px;">
💰 <strong id="game-hall-jjb" style="color:#ffe082; font-size:13px;">--</strong> 金币
</div>
<span onclick="closeGameHall()"
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 游戏卡片网格 ─── --}}
<div style="flex:1; overflow-y:auto; background:#f6faff; padding:12px;">
{{-- 加载状态 --}}
<div id="game-hall-loading" style="text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;"></div>
加载游戏状态中…
</div>
{{-- 游戏卡片容器 --}}
<div id="game-hall-cards" style="display:none; grid-template-columns:1fr 1fr; gap:10px;">
</div>
{{-- 全部未开启提示 --}}
<div id="game-hall-empty"
style="display:none; text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;">🔒</div>
暂无开启的游戏,请联系管理员
</div>
</div>
{{-- ─── 底部 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:8px 16px;
display:flex; justify-content:center; flex-shrink:0;">
<button onclick="closeGameHall()"
style="padding:5px 24px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:4px;
font-size:12px; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">关闭</button>
</div>
</div>
</div>
<script>
/** 游戏大厅配置定义ID → 展示配置) */
const GAME_HALL_GAMES = [{
id: 'baccarat',
name: '🎲 百家乐',
desc: '猜骰子大小1:1 赔率,豹子 1:24',
accentColor: '#336699',
fetchUrl: '/baccarat/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('baccarat-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.round) return {
badge: '⏸ 等待开局',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下局即将开始,稍后再来'
};
const r = data.round;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number((r.total_bet_big||0)+(r.total_bet_small||0)+(r.total_bet_triple||0)).toLocaleString()} 金`
};
}
return {
badge: '⏳ 开奖中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '正在摇骰子…'
};
},
btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情',
},
{
id: 'slot_machine',
name: '🎰 老虎机',
desc: '每日限额旋转,中奖即时到账',
accentColor: '#0891b2',
fetchUrl: null,
openFn: () => {
closeGameHall();
const panel = document.getElementById('slot-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: () => ({
badge: '✅ 随时可玩',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '每日限额抽奖,旋转即可'
}),
btnLabel: () => '🎰 开始旋转',
},
{
id: 'mystery_box',
name: '📦 神秘箱子',
desc: '管理员随机投放,抢到即开奖',
accentColor: '#b45309',
fetchUrl: '/mystery-box/status',
openFn: () => {
closeGameHall();
window.dispatchEvent(new CustomEvent('open-mystery-box'));
},
renderStatus: (data) => {
const count = data?.available_count ?? 0;
return count > 0 ? {
badge: `🎁 ${count} 个待领`,
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '箱子已投放!快去领取'
} : {
badge: '📭 暂无箱子',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '等待管理员投放'
};
},
btnLabel: (data) => (data?.available_count ?? 0) > 0 ? '🎁 立即领取' : '📭 等待投放',
},
{
id: 'horse_racing',
name: '🐎 赛马竞猜',
desc: '彩池制赛马,押注马匹赢取奖金',
accentColor: '#336699',
fetchUrl: '/horse-race/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openFromHall();
},
renderStatus: (data) => {
if (!data?.race) return {
badge: '⏸ 等待开赛',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场赛马即将开始'
};
const r = data.race;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number(r.total_pool || 0).toLocaleString()} 金`
};
}
if (r.status === 'running') {
return {
badge: '🏇 跑马中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '比赛进行中…'
};
}
return {
badge: '🏆 已结算',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场即将开始'
};
},
btnLabel: (data) => data?.race?.status === 'betting' ? '🐎 立即押注' : '📊 查看赛况',
},
{
id: 'fortune_telling',
name: '🔮 神秘占卜',
desc: '每日签文,开启今日运势加成',
accentColor: '#6d28d9',
fetchUrl: '/fortune/today',
openFn: () => {
closeGameHall();
const panel = document.getElementById('fortune-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.enabled) return {
badge: '🔒 未开启',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '此游戏暂未开启'
};
const used = data.free_used ?? 0;
const total = data.free_count ?? 1;
return data.has_free_left ? {
badge: '✨ 免费可占',
badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd',
detail: `今日已占 ${used}/${total} 次,还有免费次数`
} : {
badge: '💰 付费可占',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: `今日免费次数已用完(${data.extra_cost} 金/次)`
};
},
btnLabel: (data) => data?.has_free_left ? '🔮 免费占卜' : '🔮 付费占卜',
},
{
id: 'fishing',
name: '🎣 钓鱼',
desc: '消耗鱼饵钓取金币和道具。背包需有鱼饵才能出竿。',
accentColor: '#0d9488',
fetchUrl: null,
openFn: () => {
closeGameHall();
// 直接触发钓鱼,无需手动输入指令
if (typeof startFishing === 'function') {
startFishing();
}
},
renderStatus: () => ({
badge: '🎣 随时可钓',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '① 点击发言框上方【🎣 钓鱼】按钮 → ② 等待浮漂出现 → ③ 看到 🪝 后立刻点击收竿!'
}),
btnLabel: () => '🎣 去钓鱼',
},
{
id: 'lottery',
name: '🎟️ 双色球',
desc: '每日20:00开奖选3红1蓝按奖池比例派奖无一等奖滚存累积',
accentColor: '#dc2626',
fetchUrl: '/lottery/current',
openFn: () => {
closeGameHall();
if (typeof openLotteryPanel === 'function') openLotteryPanel();
},
renderStatus: (data) => {
if (!data?.issue) return {
badge: '⏸ 等待开期',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '暂无进行中期次'
};
const iss = data.issue;
const pool = Number(iss.pool_amount || 0).toLocaleString();
if (data.is_open) {
const h = Math.floor(iss.seconds_left / 3600);
const m = Math.floor((iss.seconds_left % 3600) / 60);
const timeStr = h > 0 ? `${h}h ${m}m` : `${m}m`;
return {
badge: iss.is_super_issue ? '🎊 超级期购票中' : '🟢 购票中',
badgeStyle: 'background:#fef2f2; color:#b91c1c; border:1px solid #fca5a5',
detail: `💰 奖池 ${pool} 金 | 距开奖 ${timeStr} | 第 ${iss.issue_no} 期`
};
}
if (iss.status === 'settled') return {
badge: '✅ 已开奖',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '本期已开奖,下期购票中'
};
return {
badge: '🔴 已停售',
badgeStyle: 'background:#fee2e2; color:#b91c1c; border:1px solid #fecaca',
detail: `💰 奖池 ${pool} 金 | 等待开奖中…`
};
},
btnLabel: (data) => data?.is_open ? '🎟️ 立即购票' : '📊 查看结果',
},
];
/**
* 打开游戏大厅弹窗,加载各游戏状态
*/
window.openGameHall = async function() {
document.getElementById('game-hall-modal').style.display = 'flex';
document.getElementById('game-hall-loading').style.display = 'block';
document.getElementById('game-hall-cards').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'none';
const jjbEl = document.getElementById('game-hall-jjb');
if (window.chatContext?.userJjb !== undefined) {
jjbEl.textContent = Number(window.chatContext.userJjb).toLocaleString();
}
// 每次打开均实时拉取后台开关状态(避免页面不刷新时开关不同步)
let enabledMap = window.GAME_ENABLED ?? {};
try {
const r = await fetch('/games/enabled', {
headers: {
'Accept': 'application/json'
}
});
if (r.ok) enabledMap = await r.json();
} catch {
/* 网络异常时降级使用页面注入值 */
}
// 过滤出后台已开启的游戏
const enabledGames = GAME_HALL_GAMES.filter(g => enabledMap[g.id] !== false);
// 并行请求有状态接口的游戏
const statuses = {};
await Promise.all(
enabledGames.filter(g => g.fetchUrl).map(async g => {
try {
const res = await fetch(g.fetchUrl);
statuses[g.id] = await res.json();
} catch {
statuses[g.id] = null;
}
})
);
renderGameCards(enabledGames, statuses);
};
/**
* 关闭游戏大厅弹窗
*/
window.closeGameHall = function() {
document.getElementById('game-hall-modal').style.display = 'none';
};
/**
* 渲染所有游戏卡片(海军蓝风格)
*
* @param {Array} games 已过滤的游戏配置列表
* @param {Object} statuses 各游戏的 API 返回数据
*/
function renderGameCards(games, statuses) {
const container = document.getElementById('game-hall-cards');
container.innerHTML = '';
if (games.length === 0) {
document.getElementById('game-hall-loading').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'block';
return;
}
games.forEach(game => {
const data = statuses[game.id] ?? null;
const status = game.renderStatus ? game.renderStatus(data) : {
badge: '✅ 可用',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: ''
};
const btnLabel = game.btnLabel ? game.btnLabel(data) : '🎮 进入';
const card = document.createElement('div');
card.style.cssText = `
background:#fff;
border:1px solid #d0e4f5;
border-left:4px solid ${game.accentColor};
border-radius:6px; padding:12px 14px;
cursor:default; transition:border-color .2s, box-shadow .2s;
display:flex; flex-direction:column; gap:8px;
`;
card.innerHTML = `
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
<div style="flex:1;">
<div style="color:#225588; font-weight:bold; font-size:13px; margin-bottom:3px;">${game.name}</div>
<div style="color:#666; font-size:11px; line-height:1.4;">${game.desc}</div>
</div>
<span style="padding:2px 8px; border-radius:10px; font-size:10px; font-weight:bold; white-space:nowrap; ${status.badgeStyle}">
${status.badge}
</span>
</div>
<div style="color:#888; font-size:10px; line-height:1.4; min-height:14px; border-top:1px dashed #e0ecf8; padding-top:6px;">${status.detail || '&nbsp;'}</div>
<button
style="width:100%; border:none; border-radius:4px; padding:7px 8px; font-size:12px; font-weight:bold;
cursor:pointer; color:#fff; transition:opacity .15s;
background:linear-gradient(135deg,${game.accentColor},${game.accentColor}cc);"
onmouseover="this.style.opacity='.85'"
onmouseout="this.style.opacity='1'">
${btnLabel}
</button>
`;
card.querySelector('button').addEventListener('click', (e) => {
e.stopPropagation();
game.openFn();
});
card.addEventListener('mouseenter', () => {
card.style.borderColor = game.accentColor;
card.style.boxShadow = `0 2px 8px rgba(51,102,153,.18)`;
});
card.addEventListener('mouseleave', () => {
card.style.borderColor = '#d0e4f5';
card.style.borderLeftColor = game.accentColor;
card.style.boxShadow = '';
});
container.appendChild(card);
});
document.getElementById('game-hall-loading').style.display = 'none';
container.style.display = 'grid';
}
// 点击遮罩关闭弹窗
document.getElementById('game-hall-modal').addEventListener('click', function(e) {
if (e.target === this) closeGameHall();
});
</script>

View File

@@ -0,0 +1,461 @@
<script>
// ── 钓鱼小游戏(随机浮漂版)─────────────────────────
let fishingTimer = null;
let fishingReelTimeout = null;
let _fishToken = null; // 当次钓鱼的 token
let _autoFishing = false; // 是否处于自动钓鱼循环中
let _autoFishCdTimer = null; // 自动钓鱼冷却计时器
let _autoFishCdCountdown = null; // 冷却倒计时 interval
/**
* 创建浮漂 DOM 元素(绝对定位在聊天框上层)
* @param {number} x 水平百分比 0-100
* @param {number} y 垂直百分比 0-100
* @returns {HTMLElement}
*/
function createBobber(x, y) {
const el = document.createElement('div');
el.id = 'fishing-bobber';
el.style.cssText = `
position: fixed;
left: ${x}vw;
top: ${y}vh;
font-size: 28px;
cursor: pointer;
z-index: 9999;
animation: bobberFloat 1.2s ease-in-out infinite;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4));
user-select: none;
transition: transform 0.3s;
`;
el.textContent = '🪝';
el.title = '鱼上钩了!快点击!';
// 注入动画
if (!document.getElementById('bobber-style')) {
const style = document.createElement('style');
style.id = 'bobber-style';
style.textContent = `
@keyframes bobberFloat {
0%,100% { transform: translateY(0) rotate(-8deg); }
50% { transform: translateY(-10px) rotate(8deg); }
}
@keyframes bobberSink {
0% { transform: translateY(0) scale(1); opacity:1; }
30% { transform: translateY(12px) scale(1.3); opacity:1; }
100% { transform: translateY(40px) scale(0.5); opacity:0; }
}
@keyframes bobberPulse {
0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.6); }
50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); }
}
#fishing-bobber.sinking {
animation: bobberSink 1.5s forwards !important;
}
`;
document.head.appendChild(style);
}
return el;
}
/** 移除浮漂 */
function removeBobber() {
const el = document.getElementById('fishing-bobber');
if (el) el.remove();
}
/**
* 开始钓鱼:调用抛竿 API随机显示浮漂位置
*/
async function startFishing() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 抛竿中...';
try {
const res = await fetch(window.chatContext.fishCastUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
window.chatDialog.alert(data.message || '钓鱼失败', '操作失败', '#cc4444');
btn.disabled = false;
btn.textContent = '🎣 钓鱼';
return;
}
// 保存本次 token收竿时提交
_fishToken = data.token;
_autoFishing = !!data.auto_fishing; // 持有自动钓鱼卡则开启循环模式
// 聊天框提示
const castDiv = document.createElement('div');
castDiv.className = 'msg-line';
const timeStr = new Date().toLocaleTimeString('zh-CN', {
hour12: false
});
castDiv.innerHTML =
`<span style="color:#2563eb;font-weight:bold;">🎣【钓鱼】</span>${data.message}<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(castDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
btn.textContent = '🎣 等待中...';
// 创建浮漂(浮漂在随机位置)
const bobber = createBobber(data.bobber_x, data.bobber_y);
document.body.appendChild(bobber);
// 等待 wait_time 秒后浮漂「下沉」
fishingTimer = setTimeout(() => {
// 播放下沉动画
bobber.classList.add('sinking');
bobber.textContent = '🐟';
const hookDiv = document.createElement('div');
hookDiv.className = 'msg-line';
if (data.auto_fishing) {
// 自动钓鱼卡:在动画结束后自动收竿
hookDiv.innerHTML =
`<span style="color:#7c3aed;font-weight:bold;">🎣 自动钓鱼卡生效!自动收竿中... <span style="font-size:10px;opacity:0.7">(剩余${data.auto_fishing_minutes_left}分钟)</span></span>`;
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
// 500ms 后自动收竿(等动画)
fishingReelTimeout = setTimeout(() => {
removeBobber();
reelFish();
}, 1800);
} else {
// 手动模式:玩家需在 8 秒内点击浮漂
hookDiv.innerHTML =
`<span style="color:#d97706;font-weight:bold;font-size:14px;">🐟 鱼上钩了!快点击屏幕上的浮漂!</span>`;
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
btn.textContent = '🎣 点击浮漂!';
// 浮漂点击事件
bobber.onclick = () => {
removeBobber();
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
reelFish();
};
// 8 秒内不点击 → 鱼跑了token 过期服务端也会拒绝)
fishingReelTimeout = setTimeout(() => {
removeBobber();
_fishToken = null;
const missDiv = document.createElement('div');
missDiv.className = 'msg-line';
missDiv.innerHTML = '<span style="color:#999;">💨 你反应太慢了,鱼跑掉了...</span>';
container2.appendChild(missDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
resetFishingBtn();
}, 8000);
}
}, data.wait_time * 1000);
} catch (e) {
window.chatDialog.alert('网络错误:' + e.message, '网络异常', '#cc4444');
removeBobber();
btn.disabled = false;
btn.textContent = '🎣 钓鱼';
}
}
/**
* 收竿 提交 token 到后端,获取随机结果
*/
async function reelFish() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 拉竿中...';
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
const token = _fishToken;
_fishToken = null;
try {
const res = await fetch(window.chatContext.fishReelUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
token
})
});
const data = await res.json();
const timeStr = new Date().toLocaleTimeString('zh-CN', {
hour12: false
});
if (res.ok && data.status === 'success') {
const r = data.result;
const color = r.exp >= 0 ? '#16a34a' : '#dc2626';
const resultDiv = document.createElement('div');
resultDiv.className = 'msg-line';
resultDiv.innerHTML =
`<span style="color:${color};font-weight:bold;">${r.emoji}【钓鱼结果】</span>${r.message}` +
` <span style="color:#666;font-size:11px;">(经验:${data.exp_num} 金币:${data.jjb}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(resultDiv);
// 自动钓鱼卡循环:等冷却时间后自动再次抛竿
if (_autoFishing) {
const cooldown = data.cooldown_seconds || 300;
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = `⏳ 冷却 ${cooldown}s`;
btn.onclick = null;
// 显示停止按钮
_showAutoFishStopBtn(cooldown);
// 倒计时更新文字
let remaining = cooldown;
_autoFishCdCountdown = setInterval(() => {
remaining--;
const b = document.getElementById('fishing-btn');
if (b) b.textContent = `⏳ 冷却 ${remaining}s`;
if (remaining <= 0) {
clearInterval(_autoFishCdCountdown);
_autoFishCdCountdown = null;
}
}, 1000);
// 冷却结束后自动抛竿
_autoFishCdTimer = setTimeout(() => {
_autoFishCdTimer = null;
_hideAutoFishStopBtn();
if (_autoFishing) startFishing(); // 仍未停止 → 继续
}, cooldown * 1000);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
return; // 不走 resetFishingBtn
}
} else {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML =
`<span style="color:red;">【钓鱼】${data.message || '操作失败'}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(errDiv);
_autoFishing = false; // 出错时停止循环
}
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
window.chatDialog.alert('网络错误:' + e.message, '网络异常', '#cc4444');
_autoFishing = false;
}
resetFishingBtn();
}
/**
* 显示「停止自动钓鱼」悬浮按钮(支持拖拽移动)
* @param {number} cooldown 冷却秒数(用于倒计时提示)
*/
function _showAutoFishStopBtn(cooldown) {
if (document.getElementById('auto-fish-stop-btn')) return;
// 注入动画样式
if (!document.getElementById('auto-fish-stop-style')) {
const s = document.createElement('style');
s.id = 'auto-fish-stop-style';
s.textContent = `
@keyframes autoFishBtnPulse {
0%,100% { box-shadow: 0 4px 12px rgba(220,38,38,0.4); }
50% { box-shadow: 0 4px 20px rgba(220,38,38,0.7); }
}
#auto-fish-stop-btn {
position: fixed;
z-index: 10000;
background: linear-gradient(135deg, #dc2626, #b91c1c);
color: #fff;
border: none;
border-radius: 20px;
padding: 8px 18px;
font-size: 13px;
font-weight: bold;
cursor: grab;
user-select: none;
animation: autoFishBtnPulse 1.8s ease-in-out infinite;
touch-action: none;
}
#auto-fish-stop-btn:active { cursor: grabbing; }
#auto-fish-stop-btn .drag-hint {
display: block;
font-size: 9px;
font-weight: normal;
opacity: .65;
margin-top: 1px;
text-align: center;
letter-spacing: .5px;
}
`;
document.head.appendChild(s);
}
const btn = document.createElement('button');
btn.id = 'auto-fish-stop-btn';
btn.innerHTML = '🛑 停止自动钓鱼<span class="drag-hint">⠿ 可拖动</span>';
// 从 localStorage 恢复上次位置,默认右下角
const saved = (() => {
try {
return JSON.parse(localStorage.getItem('autoFishBtnPos'));
} catch {
return null;
}
})();
if (saved) {
btn.style.left = saved.left + 'px';
btn.style.top = saved.top + 'px';
} else {
btn.style.bottom = '80px';
btn.style.right = '20px';
}
// ── 拖拽逻辑(鼠标 + 触摸) ──────────────────────────────────
let isDragging = false;
let startX, startY, startLeft, startTop;
function onDragStart(e) {
// 将 right/bottom 转为 left/top 绝对坐标,便于拖拽计算
const rect = btn.getBoundingClientRect();
btn.style.left = rect.left + 'px';
btn.style.top = rect.top + 'px';
btn.style.right = 'auto';
btn.style.bottom = 'auto';
isDragging = false;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
startX = clientX;
startY = clientY;
startLeft = rect.left;
startTop = rect.top;
document.addEventListener('mousemove', onDragMove, {
passive: false
});
document.addEventListener('mouseup', onDragEnd);
document.addEventListener('touchmove', onDragMove, {
passive: false
});
document.addEventListener('touchend', onDragEnd);
}
function onDragMove(e) {
e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const dx = clientX - startX;
const dy = clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDragging = true;
}
if (!isDragging) return;
const newLeft = Math.max(0, Math.min(window.innerWidth - btn.offsetWidth, startLeft + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - btn.offsetHeight, startTop + dy));
btn.style.left = newLeft + 'px';
btn.style.top = newTop + 'px';
}
function onDragEnd() {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
document.removeEventListener('touchmove', onDragMove);
document.removeEventListener('touchend', onDragEnd);
// 持久化位置
if (isDragging) {
localStorage.setItem('autoFishBtnPos', JSON.stringify({
left: parseInt(btn.style.left),
top: parseInt(btn.style.top),
}));
}
}
btn.addEventListener('mousedown', onDragStart);
btn.addEventListener('touchstart', onDragStart, {
passive: true
});
// 拖拽时不触发 click非拖拽时才停止钓鱼
btn.addEventListener('click', () => {
if (!isDragging) stopAutoFishing();
});
document.body.appendChild(btn);
}
/** 隐藏停止按钮 */
function _hideAutoFishStopBtn() {
const el = document.getElementById('auto-fish-stop-btn');
if (el) el.remove();
}
/**
* 手动停止自动钓鱼循环
*/
function stopAutoFishing() {
_autoFishing = false;
if (_autoFishCdTimer) {
clearTimeout(_autoFishCdTimer);
_autoFishCdTimer = null;
}
if (_autoFishCdCountdown) {
clearInterval(_autoFishCdCountdown);
_autoFishCdCountdown = null;
}
_hideAutoFishStopBtn();
const noticeDiv = document.createElement('div');
noticeDiv.className = 'msg-line';
noticeDiv.innerHTML = '<span style="color:#6b7280;">🛑 已停止自动钓鱼。</span>';
container2.appendChild(noticeDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
resetFishingBtn();
}
/**
* 重置钓鱼按钮状态(停止自动循环后调用)
*/
function resetFishingBtn() {
_autoFishing = false;
_hideAutoFishStopBtn();
if (_autoFishCdTimer) {
clearTimeout(_autoFishCdTimer);
_autoFishCdTimer = null;
}
if (_autoFishCdCountdown) {
clearInterval(_autoFishCdCountdown);
_autoFishCdCountdown = null;
}
const btn = document.getElementById('fishing-btn');
btn.textContent = '🎣 钓鱼';
btn.disabled = false;
btn.onclick = startFishing;
fishingTimer = null;
fishingReelTimeout = null;
removeBobber();
}
</script>

View File

@@ -72,7 +72,8 @@
</div>
{{-- 已占卜:展示签文 --}}
<div x-show="resultGrade" x-transition:enter="transition ease-out duration-500"
<div x-show="resultGrade" style="display:none;"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 scale-75"
x-transition:enter-end="opacity-100 scale-100">

View File

@@ -0,0 +1,460 @@
{{--
文件功能:娱乐游戏大厅弹窗组件
点击工具栏「娱乐」按钮后弹出,展示所有已开启的游戏:
- 百家乐:当前场次状态 + 倒计时 + 直接参与按钮
- 老虎机:今日限额余量 + 直接打开按钮
- 神秘箱子:已投放数量 + 直接打开按钮
- 赛马竞猜:当前场次状态 + 参与按钮
- 神秘占卜:今日占卜次数 + 直接打开按钮
- 钓鱼:状态 + 打开按钮
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ─── 服务端注入各游戏开关状态(避免前端额外请求)─── --}}
@php
$gameEnabled = [
'baccarat' => \App\Models\GameConfig::isEnabled('baccarat'),
'slot_machine' => \App\Models\GameConfig::isEnabled('slot_machine'),
'mystery_box' => \App\Models\GameConfig::isEnabled('mystery_box'),
'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'),
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
];
@endphp
<script>
/** 后台游戏开关状态Blade 服务端注入1分钟缓存 */
window.GAME_ENABLED = @json($gameEnabled);
</script>
{{-- ═══════════ 游戏大厅弹窗遮罩 ═══════════ --}}
<div id="game-hall-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
z-index:9998; justify-content:center; align-items:center;">
<div id="game-hall-inner"
style="width:680px; max-width:96vw; max-height:88vh; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); display:flex; flex-direction:column;
background:#fff; font-family:'Microsoft YaHei',SimSun,sans-serif;">
{{-- ─── 标题栏(与商店弹窗同风格)─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;
padding:10px 16px; display:flex; align-items:center; gap:10px; flex-shrink:0;">
<div style="font-size:14px; font-weight:bold; flex:1;">🎮 娱乐大厅</div>
<div
style="font-size:12px; color:#d0e8ff; display:flex; align-items:center; gap:3px;
background:rgba(0,0,0,.2); padding:2px 8px; border-radius:10px;">
💰 <strong id="game-hall-jjb" style="color:#ffe082; font-size:13px;">--</strong> 金币
</div>
<span onclick="closeGameHall()"
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 游戏卡片网格 ─── --}}
<div style="flex:1; overflow-y:auto; background:#f6faff; padding:12px;">
{{-- 加载状态 --}}
<div id="game-hall-loading" style="text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;"></div>
加载游戏状态中…
</div>
{{-- 游戏卡片容器 --}}
<div id="game-hall-cards" style="display:none; grid-template-columns:1fr 1fr; gap:10px;">
</div>
{{-- 全部未开启提示 --}}
<div id="game-hall-empty"
style="display:none; text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;">🔒</div>
暂无开启的游戏,请联系管理员
</div>
</div>
{{-- ─── 底部 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:8px 16px;
display:flex; justify-content:center; flex-shrink:0;">
<button onclick="closeGameHall()"
style="padding:5px 24px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:4px;
font-size:12px; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">关闭</button>
</div>
</div>
</div>
<script>
/**
* 游戏大厅模块(使用 IIFE 隔离全局作用域,防止 const 重复初始化导致脚本块失败)
*/
(function() {
/** 游戏大厅配置定义ID → 展示配置) */
const GAME_HALL_GAMES = [{
id: 'baccarat',
name: '🎲 百家乐',
desc: '猜骰子大小1:1 赔率,豹子 1:24',
accentColor: '#336699',
fetchUrl: '/baccarat/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('baccarat-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.round) return {
badge: '⏸ 等待开局',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下局即将开始,稍后再来'
};
const r = data.round;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number((r.total_bet_big||0)+(r.total_bet_small||0)+(r.total_bet_triple||0)).toLocaleString()} 金`
};
}
return {
badge: '⏳ 开奖中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '正在摇骰子…'
};
},
btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情',
},
{
id: 'slot_machine',
name: '🎰 老虎机',
desc: '每日限额旋转,中奖即时到账',
accentColor: '#0891b2',
fetchUrl: null,
openFn: () => {
closeGameHall();
const panel = document.getElementById('slot-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: () => ({
badge: '✅ 随时可玩',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '每日限额抽奖,旋转即可'
}),
btnLabel: () => '🎰 开始旋转',
},
{
id: 'mystery_box',
name: '📦 神秘箱子',
desc: '管理员随机投放,抢到即开奖',
accentColor: '#b45309',
fetchUrl: '/mystery-box/status',
openFn: () => {
closeGameHall();
window.dispatchEvent(new CustomEvent('open-mystery-box'));
},
renderStatus: (data) => {
const count = data?.available_count ?? 0;
return count > 0 ? {
badge: `🎁 ${count} 个待领`,
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '箱子已投放!快去领取'
} : {
badge: '📭 暂无箱子',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '等待管理员投放'
};
},
btnLabel: (data) => (data?.available_count ?? 0) > 0 ? '🎁 立即领取' : '📭 等待投放',
},
{
id: 'horse_racing',
name: '🐎 赛马竞猜',
desc: '彩池制赛马,押注马匹赢取奖金',
accentColor: '#336699',
fetchUrl: '/horse-race/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openFromHall();
},
renderStatus: (data) => {
if (!data?.race) return {
badge: '⏸ 等待开赛',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场赛马即将开始'
};
const r = data.race;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number(r.total_pool || 0).toLocaleString()} 金`
};
}
if (r.status === 'running') {
return {
badge: '🏇 跑马中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '比赛进行中…'
};
}
return {
badge: '🏆 已结算',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场即将开始'
};
},
btnLabel: (data) => data?.race?.status === 'betting' ? '🐎 立即押注' : '📊 查看赛况',
},
{
id: 'fortune_telling',
name: '🔮 神秘占卜',
desc: '每日签文,开启今日运势加成',
accentColor: '#6d28d9',
fetchUrl: '/fortune/today',
openFn: () => {
closeGameHall();
const panel = document.getElementById('fortune-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.enabled) return {
badge: '🔒 未开启',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '此游戏暂未开启'
};
const used = data.free_used ?? 0;
const total = data.free_count ?? 1;
return data.has_free_left ? {
badge: '✨ 免费可占',
badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd',
detail: `今日已占 ${used}/${total} 次,还有免费次数`
} : {
badge: '💰 付费可占',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: `今日免费次数已用完(${data.extra_cost} 金/次)`
};
},
btnLabel: (data) => data?.has_free_left ? '🔮 免费占卜' : '🔮 付费占卜',
},
{
id: 'fishing',
name: '🎣 钓鱼',
desc: '消耗鱼饵钓取金币和道具。背包需有鱼饵才能出竿。',
accentColor: '#0d9488',
fetchUrl: null,
openFn: () => {
closeGameHall();
// 直接触发钓鱼,无需手动输入指令
if (typeof startFishing === 'function') {
startFishing();
}
},
renderStatus: () => ({
badge: '🎣 随时可钓',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '① 点击发言框上方【🎣 钓鱼】按钮 → ② 等待浮漂出现 → ③ 看到 🪝 后立刻点击收竿!'
}),
btnLabel: () => '🎣 去钓鱼',
},
{
id: 'lottery',
name: '🎟️ 双色球',
desc: '每日20:00开奖选3红1蓝按奖池比例派奖无一等奖滚存累积',
accentColor: '#dc2626',
fetchUrl: '/lottery/current',
openFn: () => {
closeGameHall();
if (typeof openLotteryPanel === 'function') openLotteryPanel();
},
renderStatus: (data) => {
if (!data?.issue) return {
badge: '⏸ 等待开期',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '暂无进行中期次'
};
const iss = data.issue;
const pool = Number(iss.pool_amount || 0).toLocaleString();
if (data.is_open) {
const h = Math.floor(iss.seconds_left / 3600);
const m = Math.floor((iss.seconds_left % 3600) / 60);
const timeStr = h > 0 ? `${h}h ${m}m` : `${m}m`;
return {
badge: iss.is_super_issue ? '🎊 超级期购票中' : '🟢 购票中',
badgeStyle: 'background:#fef2f2; color:#b91c1c; border:1px solid #fca5a5',
detail: `💰 奖池 ${pool} 金 | 距开奖 ${timeStr} | 第 ${iss.issue_no} 期`
};
}
if (iss.status === 'settled') return {
badge: '✅ 已开奖',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '本期已开奖,下期购票中'
};
return {
badge: '🔴 已停售',
badgeStyle: 'background:#fee2e2; color:#b91c1c; border:1px solid #fecaca',
detail: `💰 奖池 ${pool} 金 | 等待开奖中…`
};
},
btnLabel: (data) => data?.is_open ? '🎟️ 立即购票' : '📊 查看结果',
},
];
/**
* 打开游戏大厅弹窗,加载各游戏状态
*/
window.openGameHall = async function() {
const modal = document.getElementById('game-hall-modal');
if (!modal) {
console.error('[游戏大厅] game-hall-modal 元素不存在,请检查模板加载');
return;
}
modal.style.display = 'flex';
document.getElementById('game-hall-loading').style.display = 'block';
document.getElementById('game-hall-cards').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'none';
const jjbEl = document.getElementById('game-hall-jjb');
if (jjbEl && window.chatContext?.userJjb !== undefined) {
jjbEl.textContent = Number(window.chatContext.userJjb).toLocaleString();
}
// 每次打开均实时拉取后台开关状态(避免页面不刷新时开关不同步)
let enabledMap = window.GAME_ENABLED ?? {};
try {
const r = await fetch('/games/enabled', {
headers: {
'Accept': 'application/json'
}
});
if (r.ok) enabledMap = await r.json();
} catch {
/* 网络异常时降级使用页面注入值 */
}
// 过滤出后台已开启的游戏
const enabledGames = GAME_HALL_GAMES.filter(g => enabledMap[g.id] !== false);
// 并行请求有状态接口的游戏
const statuses = {};
await Promise.all(
enabledGames.filter(g => g.fetchUrl).map(async g => {
try {
const res = await fetch(g.fetchUrl);
statuses[g.id] = await res.json();
} catch {
statuses[g.id] = null;
}
})
);
try {
renderGameCards(enabledGames, statuses);
} catch (err) {
console.error('[游戏大厅] 渲染游戏卡片失败:', err);
document.getElementById('game-hall-loading').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'block';
}
};
/**
* 关闭游戏大厅弹窗
*/
window.closeGameHall = function() {
document.getElementById('game-hall-modal').style.display = 'none';
};
/**
* 渲染所有游戏卡片(海军蓝风格)
*
* @param {Array} games 已过滤的游戏配置列表
* @param {Object} statuses 各游戏的 API 返回数据
*/
function renderGameCards(games, statuses) {
const container = document.getElementById('game-hall-cards');
container.innerHTML = '';
if (games.length === 0) {
document.getElementById('game-hall-loading').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'block';
return;
}
games.forEach(game => {
try {
const data = statuses[game.id] ?? null;
const status = game.renderStatus ? game.renderStatus(data) : {
badge: '✅ 可用',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: ''
};
const btnLabel = game.btnLabel ? game.btnLabel(data) : '🎮 进入';
const card = document.createElement('div');
card.style.cssText = `
background:#fff;
border:1px solid #d0e4f5;
border-left:4px solid ${game.accentColor};
border-radius:6px; padding:12px 14px;
cursor:default; transition:border-color .2s, box-shadow .2s;
display:flex; flex-direction:column; gap:8px;
`;
card.innerHTML = `
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
<div style="flex:1;">
<div style="color:#225588; font-weight:bold; font-size:13px; margin-bottom:3px;">${game.name}</div>
<div style="color:#666; font-size:11px; line-height:1.4;">${game.desc}</div>
</div>
<span style="padding:2px 8px; border-radius:10px; font-size:10px; font-weight:bold; white-space:nowrap; ${status.badgeStyle}">
${status.badge}
</span>
</div>
<div style="color:#888; font-size:10px; line-height:1.4; min-height:14px; border-top:1px dashed #e0ecf8; padding-top:6px;">${status.detail || '&nbsp;'}</div>
<button
style="width:100%; border:none; border-radius:4px; padding:7px 8px; font-size:12px; font-weight:bold;
cursor:pointer; color:#fff; transition:opacity .15s;
background:linear-gradient(135deg,${game.accentColor},${game.accentColor}cc);"
onmouseover="this.style.opacity='.85'"
onmouseout="this.style.opacity='1'">
${btnLabel}
</button>
`;
card.querySelector('button').addEventListener('click', (e) => {
e.stopPropagation();
game.openFn();
});
card.addEventListener('mouseenter', () => {
card.style.borderColor = game.accentColor;
card.style.boxShadow = `0 2px 8px rgba(51,102,153,.18)`;
});
card.addEventListener('mouseleave', () => {
card.style.borderColor = '#d0e4f5';
card.style.borderLeftColor = game.accentColor;
card.style.boxShadow = '';
});
container.appendChild(card);
} catch (err) {
// 单个游戏卡片渲染失败不影响其他游戏展示
console.warn(`[游戏大厅] 游戏 ${game.id} 卡片渲染失败:`, err);
}
});
document.getElementById('game-hall-loading').style.display = 'none';
container.style.display = 'grid';
}
// 点击遮罩关闭弹窗
document.getElementById('game-hall-modal').addEventListener('click', function(e) {
if (e.target === this) closeGameHall();
});
})(); // end IIFE
</script>

View File

@@ -119,12 +119,12 @@
style="padding:10px 14px; background:#fff5f5; border-bottom:1px solid #fee2e2; text-align:center;">
<div style="font-size:12px; color:#b91c1c; font-weight:bold; margin-bottom:6px;">🎊 开奖结果</div>
<div style="display:flex; justify-content:center; gap:8px; align-items:center; flex-wrap:wrap;">
<template x-for="n in [drawRed1, drawRed2, drawRed3]" :key="n">
<template x-for="(n, $index) in [drawRed1, drawRed2, drawRed3]" :key="$index">
<span
style="width:32px; height:32px; border-radius:50%; background:#dc2626;
color:#fff; font-weight:bold; font-size:14px;
display:flex; align-items:center; justify-content:center;"
x-text="String(n).padStart(2,'0')"></span>
x-text="n !== null ? String(n).padStart(2,'0') : '--'"></span>
</template>
<span style="color:#6b7280; font-size:18px; font-weight:bold;">+</span>
<span
@@ -290,7 +290,7 @@
font-size:11px; padding:4px 0; border-bottom:1px dashed #fee2e2;">
<div style="display:flex; align-items:center; gap:3px;">
<span style="color:#9ca3af; min-width:28px;" x-text="'注'+(idx+1)"></span>
<template x-for="r in [t.red1,t.red2,t.red3]" :key="r">
<template x-for="(r, $ri) in [t.red1,t.red2,t.red3]" :key="$ri">
<span
style="width:20px; height:20px; border-radius:50%; background:#dc2626;
color:#fff; font-size:10px; text-align:center; line-height:20px;"
@@ -331,7 +331,8 @@
<tr :style="i % 2 === 0 ? '' : 'background:#fff7f7;'">
<td style="padding:3px 5px; color:#6b7280;" x-text="h.issue_no"></td>
<td style="padding:3px 5px; text-align:center;">
<template x-for="r in [h.red1,h.red2,h.red3]" :key="r">
<template x-for="(r, $ri) in [h.red1,h.red2,h.red3]"
:key="$ri">
<span
style="display:inline-block; width:18px; height:18px; border-radius:50%;
background:#dc2626; color:#fff; font-size:9px; font-weight:bold;
@@ -386,257 +387,258 @@
</div>
</div>
</div>
<script>
/**
* 双色球彩票面板 Alpine.js 组件
*/
function lotteryPanel() {
return {
// ─── 状态 ───
show: false,
loading: true,
ruleOpen: false,
buying: false,
<script>
/**
* 双色球彩票面板 Alpine.js 组件
*/
function lotteryPanel() {
return {
// ─── 状态 ───
show: false,
loading: true,
ruleOpen: false,
buying: false,
// ─── 期次数据 ───
issueNo: '--',
status: 'open',
isOpen: false,
isSuperIssue: false,
poolAmount: 0,
secondsLeft: 0,
drawAt: null,
drawRed1: null,
drawRed2: null,
drawRed3: null,
drawBlue: null,
// ─── 期次数据 ───
issueNo: '--',
status: 'open',
isOpen: false,
isSuperIssue: false,
poolAmount: 0,
secondsLeft: 0,
drawAt: null,
drawRed1: null,
drawRed2: null,
drawRed3: null,
drawBlue: null,
// ─── 选号 ───
selectedReds: [],
selectedBlue: null,
cart: [], // 待购清单
// ─── 选号 ───
selectedReds: [],
selectedBlue: null,
cart: [], // 待购清单
// ─── 数据 ───
myTickets: [],
history: [],
// ─── 数据 ───
myTickets: [],
history: [],
// ─── 倒计时 ───
_timer: null,
// ─── 倒计时 ───
_timer: null,
/**
* 格式化倒计时文字(如 4h 22m 01:59
*/
get countdownText() {
const s = this.secondsLeft;
if (s <= 0) return '即将开奖';
if (s >= 3600) return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
},
/**
* 格式化倒计时文字(如 4h 22m 01:59
*/
get countdownText() {
const s = this.secondsLeft;
if (s <= 0) return '即将开奖';
if (s >= 3600) return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
},
/**
* 打开面板并加载数据
*/
async open() {
this.show = true;
await this.loadData();
this.startTimer();
},
/**
* 打开面板并加载数据
*/
async open() {
this.show = true;
await this.loadData();
this.startTimer();
},
/**
* 加载当期状态、我的购票、历史开奖
*/
async loadData() {
this.loading = true;
try {
const [currentRes, histRes] = await Promise.all([
fetch('/lottery/current', {
headers: {
'Accept': 'application/json'
}
}),
fetch('/lottery/history', {
headers: {
'Accept': 'application/json'
}
}),
]);
const current = await currentRes.json();
const hist = await histRes.json();
if (current.issue) {
const iss = current.issue;
this.issueNo = iss.issue_no;
this.status = iss.status;
this.isOpen = current.is_open;
this.isSuperIssue = iss.is_super_issue;
this.poolAmount = iss.pool_amount;
this.secondsLeft = iss.seconds_left;
this.drawRed1 = iss.red1;
this.drawRed2 = iss.red2;
this.drawRed3 = iss.red3;
this.drawBlue = iss.blue;
}
this.myTickets = current.my_tickets ?? [];
this.history = hist.issues ?? [];
} catch (e) {
// 网络异常静默处理
}
this.loading = false;
},
/**
* 启动倒计时 ticker
*/
startTimer() {
clearInterval(this._timer);
this._timer = setInterval(() => {
if (this.secondsLeft > 0) {
this.secondsLeft--;
}
}, 1000);
},
/**
* 切换红球选择状态
*/
toggleRed(n) {
if (this.selectedReds.includes(n)) {
this.selectedReds = this.selectedReds.filter(r => r !== n);
} else if (this.selectedReds.length < 3) {
this.selectedReds = [...this.selectedReds, n];
}
},
/**
* 服务端机选号码(立即加入购物车)
*/
async doQuickPick(count = 1) {
try {
const res = await fetch(`/lottery/quick-pick?count=${count}`);
const data = await res.json();
for (const num of data.numbers) {
if (this.cart.length < 10) {
this.cart.push({
reds: num.reds,
blue: num.blue,
quick: true
});
/**
* 加载当期状态、我的购票、历史开奖
*/
async loadData() {
this.loading = true;
try {
const [currentRes, histRes] = await Promise.all([
fetch('/lottery/current', {
headers: {
'Accept': 'application/json'
}
}
// 清空当前选号
this.selectedReds = [];
this.selectedBlue = null;
} catch {}
},
}),
fetch('/lottery/history', {
headers: {
'Accept': 'application/json'
}
}),
]);
const current = await currentRes.json();
const hist = await histRes.json();
/**
* 将当前选号加入购物车
*/
addToCart() {
if (this.selectedReds.length !== 3 || !this.selectedBlue) {
showLotteryMsg('⚠️ 请选满 3 个红球和 1 个蓝球', false);
return;
if (current.issue) {
const iss = current.issue;
this.issueNo = iss.issue_no;
this.status = iss.status;
this.isOpen = current.is_open;
this.isSuperIssue = iss.is_super_issue;
this.poolAmount = iss.pool_amount;
this.secondsLeft = iss.seconds_left;
this.drawRed1 = iss.red1;
this.drawRed2 = iss.red2;
this.drawRed3 = iss.red3;
this.drawBlue = iss.blue;
}
if (this.cart.length >= 10) {
showLotteryMsg('⚠️ 单次最多加入 10 注', false);
return;
this.myTickets = current.my_tickets ?? [];
this.history = hist.issues ?? [];
} catch (e) {
// 网络异常静默处理
}
this.loading = false;
},
/**
* 启动倒计时 ticker
*/
startTimer() {
clearInterval(this._timer);
this._timer = setInterval(() => {
if (this.secondsLeft > 0) {
this.secondsLeft--;
}
this.cart.push({
reds: [...this.selectedReds].sort((a, b) => a - b),
blue: this.selectedBlue,
quick: false
});
// 清空选号等待下一注
}, 1000);
},
/**
* 切换红球选择状态
*/
toggleRed(n) {
if (this.selectedReds.includes(n)) {
this.selectedReds = this.selectedReds.filter(r => r !== n);
} else if (this.selectedReds.length < 3) {
this.selectedReds = [...this.selectedReds, n];
}
},
/**
* 服务端机选号码(立即加入购物车)
*/
async doQuickPick(count = 1) {
try {
const res = await fetch(`/lottery/quick-pick?count=${count}`);
const data = await res.json();
for (const num of data.numbers) {
if (this.cart.length < 10) {
this.cart.push({
reds: num.reds,
blue: num.blue,
quick: true
});
}
}
// 清空当前选号
this.selectedReds = [];
this.selectedBlue = null;
},
} catch {}
},
/**
* 提交购物车(批量购买)
*/
async submitCart() {
if (this.cart.length === 0 || this.buying) return;
this.buying = true;
try {
const res = await fetch('/lottery/buy', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
numbers: this.cart.map(c => ({
reds: c.reds,
blue: c.blue
})),
quick_pick: false,
}),
});
const data = await res.json();
if (res.ok && data.status === 'success') {
showLotteryMsg('✅ ' + data.message, true);
this.cart = [];
/**
* 将当前选号加入购物车
*/
addToCart() {
if (this.selectedReds.length !== 3 || !this.selectedBlue) {
showLotteryMsg('⚠️ 请选满 3 个红球和 1 个蓝球', false);
return;
}
if (this.cart.length >= 10) {
showLotteryMsg('⚠️ 单次最多加入 10 注', false);
return;
}
this.cart.push({
reds: [...this.selectedReds].sort((a, b) => a - b),
blue: this.selectedBlue,
quick: false
});
// 清空选号等待下一注
this.selectedReds = [];
this.selectedBlue = null;
},
// 更新金币余额显示
if (window.chatContext) {
window.chatContext.userJjb = Math.max(0, (window.chatContext.userJjb ?? 0) - data
.count * 100);
}
/**
* 提交购物车(批量购买)
*/
async submitCart() {
if (this.cart.length === 0 || this.buying) return;
this.buying = true;
try {
const res = await fetch('/lottery/buy', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
numbers: this.cart.map(c => ({
reds: c.reds,
blue: c.blue
})),
quick_pick: false,
}),
});
const data = await res.json();
if (res.ok && data.status === 'success') {
showLotteryMsg('✅ ' + data.message, true);
this.cart = [];
// 刷新我的购票列表
await this.loadData();
} else {
showLotteryMsg('❌ ' + (data.message || '购票失败'), false);
// 更新金币余额显示
if (window.chatContext) {
window.chatContext.userJjb = Math.max(0, (window.chatContext.userJjb ?? 0) - data
.count * 100);
}
} catch {
showLotteryMsg('🌐 网络异常,请稍后重试', false);
// 刷新我的购票列表
await this.loadData();
} else {
showLotteryMsg('❌ ' + (data.message || '购票失败'), false);
}
this.buying = false;
},
};
}
/**
* 显示彩票面板内联消息3s 后自动消失)
*
* @param {string} msg
* @param {boolean} success
*/
function showLotteryMsg(msg, success) {
const el = document.getElementById('lottery-buy-msg');
if (!el) return;
el.style.background = success ? '#f0fdf4' : '#fff5f5';
el.style.border = success ? '1px solid #86efac' : '1px solid #fecaca';
el.style.color = success ? '#16a34a' : '#dc2626';
el.textContent = msg;
el.style.display = 'block';
el.style.opacity = '1';
clearTimeout(el._t);
el._t = setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => {
el.style.display = 'none';
}, 400);
}, 3000);
}
/** 打开彩票面板 */
window.openLotteryPanel = function() {
const panel = document.getElementById('lottery-panel');
if (panel) Alpine.$data(panel).open();
} catch {
showLotteryMsg('🌐 网络异常,请稍后重试', false);
}
this.buying = false;
},
};
}
/** 关闭彩票面板 */
window.closeLotteryPanel = function() {
const panel = document.getElementById('lottery-panel');
if (panel) {
const data = Alpine.$data(panel);
data.show = false;
clearInterval(data._timer);
}
};
</script>
/**
* 显示彩票面板内联消息3s 后自动消失)
*
* @param {string} msg
* @param {boolean} success
*/
function showLotteryMsg(msg, success) {
const el = document.getElementById('lottery-buy-msg');
if (!el) return;
el.style.background = success ? '#f0fdf4' : '#fff5f5';
el.style.border = success ? '1px solid #86efac' : '1px solid #fecaca';
el.style.color = success ? '#16a34a' : '#dc2626';
el.textContent = msg;
el.style.display = 'block';
el.style.opacity = '1';
clearTimeout(el._t);
el._t = setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => {
el.style.display = 'none';
}, 400);
}, 3000);
}
/** 打开彩票面板 */
window.openLotteryPanel = function() {
const panel = document.getElementById('lottery-panel');
if (panel) Alpine.$data(panel).open();
};
/** 关闭彩票面板 */
window.closeLotteryPanel = function() {
const panel = document.getElementById('lottery-panel');
if (panel) {
const data = Alpine.$data(panel);
data.show = false;
clearInterval(data._timer);
}
};
</script>

View File

@@ -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>

View File

@@ -98,7 +98,7 @@
{{-- \u7ed3\u679c\u63d0\u793a --}}
<div style="text-align:center; min-height:34px; margin-bottom:10px;">
<div x-show="!spinning && resultLabel" x-transition
style="display:inline-block; padding:4px 18px; border-radius:20px; font-weight:bold; font-size:13px;"
style="display:none; padding:4px 18px; border-radius:20px; font-weight:bold; font-size:13px;"
:style="resultType === 'jackpot' ?
'background:linear-gradient(135deg,#fbbf24,#f59e0b); color:#fff; box-shadow:0 0 16px rgba(251,191,36,.4);' :
resultType === 'triple_gem' ?
@@ -186,7 +186,7 @@
<span x-text="open ? '▲ 收起' : '▼ 展开'" style="font-size:10px; color:#99b0cc;"></span>
</button>
<div x-show="open" x-transition
style="margin-top:6px; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
style="display:none; margin-top:6px; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
padding:10px 12px; font-size:11px; color:#446688; line-height:1.8;">
<div style="font-weight:bold; color:#336699; margin-bottom:6px; font-size:12px;">🎰 如何游玩</div>
<div> 点击 <strong>SPIN</strong> 按钮消耗金币抽奖,系统随机确定三列图案</div>

View File

@@ -1,15 +1,16 @@
{{--
文件功能:全局自定义弹窗组件(替代原生 alert / confirm
文件功能:全局自定义弹窗组件(替代原生 alert / confirm / prompt
提供全局 JS API
- window.chatDialog.alert(message, title?, color?) Promise<void>
- window.chatDialog.confirm(message, title?, color?) Promise<boolean>
- window.chatDialog.alert(message, title?, color?) Promise<void>
- window.chatDialog.confirm(message, title?, color?) Promise<boolean>
- window.chatDialog.prompt(message, defaultVal?, title?, color?) Promise<string|null>
任何 JS 代码Alpine.js 组件、toolbar、scripts 等)均可直接调用,
无需使用浏览器原生弹窗,避免 Chrome/Edge 兼容性问题。
@author ChatRoom Laravel
@version 1.0.0
@version 2.0.0
--}}
{{-- ─── 全局弹窗遮罩 ─── --}}
@@ -27,10 +28,20 @@
{{-- 内容区 --}}
<div id="global-dialog-message"
style="padding:18px 18px 14px; font-size:13px; color:#374151; white-space:pre-wrap;
style="padding:18px 18px 8px; font-size:13px; color:#374151; white-space:pre-wrap;
line-height:1.6; word-break:break-word;">
</div>
{{-- 输入框prompt 模式专用) --}}
<div id="global-dialog-input-wrap" style="display:none; padding:0 18px 12px;">
<textarea id="global-dialog-input"
style="width:100%; box-sizing:border-box; border:1px solid #d1d5db; border-radius:6px;
padding:8px 10px; font-size:13px; color:#374151; resize:vertical;
min-height:72px; line-height:1.5; outline:none; font-family:inherit;
transition:border-color .15s;"
placeholder="请输入内容…"></textarea>
</div>
{{-- 按钮区 --}}
<div style="display:flex; gap:10px; padding:0 18px 16px;">
<button id="global-dialog-cancel-btn" onclick="window.chatDialog._cancel()"
@@ -95,27 +106,38 @@
*/
window.chatDialog = (function() {
let _resolve = null;
let _currentType = 'alert';
const $ = id => document.getElementById(id);
/** 打开弹窗内部方法 */
function _open({
message,
title,
color,
type
}) {
function _open({ message, title, color, type, defaultVal }) {
_currentType = type;
$('global-dialog-header').textContent = title;
$('global-dialog-header').style.background = color;
$('global-dialog-message').textContent = message;
$('global-dialog-confirm-btn').style.background = color;
// 撤销/alert 模式隐藏取消按钮
const cancelBtn = $('global-dialog-cancel-btn');
cancelBtn.style.display = type === 'confirm' ? '' : 'none';
// prompt 模式显示输入框,并填入默认值
const inputWrap = $('global-dialog-input-wrap');
const inputEl = $('global-dialog-input');
if (type === 'prompt') {
inputEl.value = defaultVal ?? '';
inputWrap.style.display = '';
// 弹出后自动聚焦
setTimeout(() => { inputEl.focus(); inputEl.select(); }, 80);
} else {
inputWrap.style.display = 'none';
inputEl.value = '';
}
// 调整确认按钮宽度
$('global-dialog-confirm-btn').style.flex = type === 'confirm' ? '1' : '1 1 100%';
// alert 模式隐藏取消按钮
const cancelBtn = $('global-dialog-cancel-btn');
cancelBtn.style.display = (type === 'confirm' || type === 'prompt') ? '' : 'none';
// alert 模式确认按钮撑满
$('global-dialog-confirm-btn').style.flex = type === 'alert' ? '1 1 100%' : '1';
$('global-dialog-modal').style.display = 'flex';
}
@@ -132,12 +154,7 @@
alert(message, title = '提示', color = '#336699') {
return new Promise(resolve => {
_resolve = resolve;
_open({
message,
title,
color,
type: 'alert'
});
_open({ message, title, color, type: 'alert' });
});
},
@@ -152,24 +169,46 @@
confirm(message, title = '请确认', color = '#cc4444') {
return new Promise(resolve => {
_resolve = resolve;
_open({
message,
title,
color,
type: 'confirm'
});
_open({ message, title, color, type: 'confirm' });
});
},
/**
* 显示文本输入弹窗(替代 prompt
*
* @param {string} message 提示文字
* @param {string} defaultVal 输入框默认值
* @param {string} title 标题(默认:请输入)
* @param {string} color 标题栏颜色(默认蓝色)
* @return {Promise<string|null>} 确定返回输入内容,取消返回 null
*/
prompt(message, defaultVal = '', title = '请输入', color = '#336699') {
return new Promise(resolve => {
_resolve = resolve;
_open({ message, title, color, type: 'prompt', defaultVal });
});
},
/** 点确定按钮 */
_confirm() {
_resolve?.(true);
if (_currentType === 'prompt') {
// prompt 模式:返回输入框内容
_resolve?.($('global-dialog-input').value);
} else if (_currentType === 'confirm') {
_resolve?.(true);
} else {
_resolve?.();
}
this._hide();
},
/** 点取消按钮 */
_cancel() {
_resolve?.(false);
if (_currentType === 'prompt') {
_resolve?.(null);
} else {
_resolve?.(false);
}
this._hide();
},
@@ -177,7 +216,19 @@
_hide() {
$('global-dialog-modal').style.display = 'none';
_resolve = null;
_currentType = 'alert';
},
};
})();
// prompt 模式支持按 Enter 确认、Esc 取消
document.getElementById('global-dialog-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
window.chatDialog._confirm();
} else if (e.key === 'Escape') {
e.preventDefault();
window.chatDialog._cancel();
}
});
</script>

View File

@@ -180,8 +180,126 @@
{{-- ═══════════ 好友面板(独立文件)═══════════ --}}
@include('chat.partials.friend-panel')
{{-- ═══════════ 娱乐游戏大厅弹窗games/ 子目录)═══════════ --}}
@include('chat.partials.games.game-hall')
{{-- ═══════════ 工具条相关 JS 函数 ═══════════ --}}
<script>
// ── 头像选择器(与上方 #avatar-picker-modal DOM 对应)──────────────
let avatarPickerLoaded = false;
/**
* 打开头像选择弹窗
*/
function openAvatarPicker() {
const modal = document.getElementById('avatar-picker-modal');
modal.style.display = 'flex';
if (!avatarPickerLoaded) {
loadHeadfaces();
avatarPickerLoaded = true;
}
}
/**
* 关闭头像选择弹窗
*/
function closeAvatarPicker() {
document.getElementById('avatar-picker-modal').style.display = 'none';
}
/**
* 加载头像列表(懒加载,首次打开时请求)
*/
async function loadHeadfaces() {
const grid = document.getElementById('avatar-grid');
grid.innerHTML = '<div style="text-align:center;padding:20px;color:#999;">加载中...</div>';
try {
const res = await fetch('/headface/list');
const data = await res.json();
grid.innerHTML = '';
data.headfaces.forEach(file => {
const img = document.createElement('img');
img.src = '/images/headface/' + file;
img.className = 'avatar-option';
img.title = file;
img.dataset.file = file;
img.onerror = () => img.style.display = 'none';
img.onclick = () => selectAvatar(file, img);
grid.appendChild(img);
});
} catch (e) {
grid.innerHTML = '<div style="text-align:center;padding:20px;color:red;">加载失败</div>';
}
}
/**
* 选中一个头像(高亮选中状态)
*
* @param {string} file 头像文件名
* @param {HTMLElement} imgEl 被点击的 img 元素
*/
function selectAvatar(file, imgEl) {
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
imgEl.classList.add('selected');
document.getElementById('avatar-preview').src = '/images/headface/' + file;
document.getElementById('avatar-selected-name').textContent = file;
document.getElementById('avatar-save-btn').disabled = false;
document.getElementById('avatar-save-btn').dataset.file = file;
}
/**
* 保存选中的头像(调用 API 更新,成功后刷新用户列表)
*/
async function saveAvatar() {
const btn = document.getElementById('avatar-save-btn');
const file = btn.dataset.file;
if (!file) {
return;
}
btn.disabled = true;
btn.textContent = '保存中...';
try {
const res = await fetch('/headface/change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({
headface: file
})
});
const data = await res.json();
if (data.status === 'success') {
window.chatDialog.alert('头像修改成功!', '提示', '#16a34a');
// 同步更新内存中的在线用户头像,避免重新渲染前闪烁旧图
const myName = window.chatContext.username;
if (typeof onlineUsers !== 'undefined' && onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
if (typeof renderUserList === 'function') {
renderUserList();
}
closeAvatarPicker();
} else {
window.chatDialog.alert(data.message || '修改失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('网络错误', '网络异常', '#cc4444');
}
btn.disabled = false;
btn.textContent = '确定更换';
}
/**
* 保存密码(调用修改密码 API
*/

View File

@@ -136,7 +136,7 @@
必须选择婚礼档位,婚礼红包会撒给在场所有人!</div>
<div x-show="selectedTier" x-transition
style="border-radius:12px; padding:12px 14px; font-size:12px; line-height:1.7; transition:all .2s;"
style="display:none; border-radius:12px; padding:12px 14px; font-size:12px; line-height:1.7; transition:all .2s;"
:style="canAfford ? 'background:#f0fdf4; border:1.5px solid #bbf7d0;' :
'background:#fef2f2; border:1.5px solid #fecaca;'">
<div style="font-weight:700; margin-bottom:4px;"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
<script>
// ══════════════════════════════════════════
// 任命公告:复用现有礼花特效 + 隆重弹窗
// ══════════════════════════════════════════
/**
* 显示任命公告弹窗居中5 秒后淡出)
*/
/**
* 显示任命公告弹窗(改用 chatBanner 公共组件)。
*
* @param {Object} data 任命数据type, target_username, position_icon, position_name, department_name, operator_name
*/
function showAppointmentBanner(data) {
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
const isRevoke = data.type === 'revoke';
if (isRevoke) {
window.chatBanner.show({
id: 'appointment-banner',
icon: '📋',
title: '职务撤销',
name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`,
body: `<strong style="color:#f3f4f6;">${dept}${escapeHtml(data.position_name)}</strong> 职务已被撤销`,
sub: `由 ${escapeHtml(data.operator_name)} 执行`,
gradient: ['#374151', '#4b5563', '#6b7280'],
titleColor: '#d1d5db',
autoClose: 4500,
});
} else {
window.chatBanner.show({
id: 'appointment-banner',
icon: '🎊🎖️🎊',
title: '任命公告',
name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`,
body: `荣任 <strong style="color:#fde68a;">${dept}${escapeHtml(data.position_name)}</strong>`,
sub: `由 ${escapeHtml(data.operator_name)} 任命`,
gradient: ['#4f46e5', '#7c3aed', '#db2777'],
titleColor: '#fde68a',
autoClose: 4500,
});
}
}
/**
* 监听任命公告事件:根据 type 区分任命(礼花+紫色弹窗)和撤销(灰色弹窗)
*/
window.addEventListener('chat:appointment-announced', (e) => {
const data = e.detail;
const isRevoke = data.type === 'revoke';
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
// ── 任命才有礼花 ──
if (!isRevoke && typeof EffectManager !== 'undefined') {
EffectManager.play('fireworks');
}
showAppointmentBanner(data);
// ── 聊天区系统消息:操作者/被操作者 → 私聊面板;其余人 → 公屏 ──
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const myName = window.chatContext?.username ?? '';
const isInvolved = myName === data.operator_name || myName === data.target_username;
// 随机鼓励语库
const appointPhrases = [
'望再接再厉,大展宏图,为大家服务!',
'期待在任期间带领大家更上一层楼!',
'众望所归,任重道远,加油!',
'新官上任,一展风采,前程似锦!',
'相信你能胜任,期待你的精彩表现!',
];
const revokePhrases = [
'感谢在任期间的辛勤付出,辛苦了!',
'江湖路长,愿前程似锦,未来可期!',
'感谢您为大家的奉献,一路顺风!',
'在任一场,情谊长存,感谢付出!',
'相信以后还有更多精彩,继续加油!',
];
const randomPhrase = isRevoke ?
revokePhrases[Math.floor(Math.random() * revokePhrases.length)] :
appointPhrases[Math.floor(Math.random() * appointPhrases.length)];
// 构建消息 DOM内容相同分配到不同面板
function buildSysMsg() {
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
if (isRevoke) {
sysDiv.style.cssText =
'background:#f3f4f6; border-left:3px solid #9ca3af; border-radius:4px; padding:4px 10px; margin:2px 0;';
sysDiv.innerHTML =
`<span style="color:#6b7280;">📋 </span>` +
`<span style="color:#374151;"><b>${escapeHtml(data.target_username)}</b> 的 ${escapeHtml(data.position_icon)} ${dept}${escapeHtml(data.position_name)} 职务已被 <b>${escapeHtml(data.operator_name)}</b> 撤销。${randomPhrase}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
} else {
sysDiv.style.cssText =
'background:#f5f3ff; border-left:3px solid #7c3aed; border-radius:4px; padding:4px 10px; margin:2px 0;';
sysDiv.innerHTML =
`<span style="color:#7c3aed;">🎖️ </span>` +
`<span style="color:#3730a3;">恭喜 <b>${escapeHtml(data.target_username)}</b> 荣任 ${escapeHtml(data.position_icon)} ${dept}<b>${escapeHtml(data.position_name)}</b>,由 <b>${escapeHtml(data.operator_name)}</b> 任命。${randomPhrase}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
}
return sysDiv;
}
const say1 = document.getElementById('chat-messages-container');
const say2 = document.getElementById('chat-messages-container2');
if (isInvolved) {
// 操作者 / 被操作者:消息进私聊面板(包厢窗口)
if (say2) {
say2.appendChild(buildSysMsg());
say2.scrollTop = say2.scrollHeight;
}
} else {
// 其他人:消息进公屏
if (say1) {
say1.appendChild(buildSysMsg());
say1.scrollTop = say1.scrollHeight;
}
}
});
</script>

View File

@@ -121,6 +121,7 @@
// 自定义弹窗:直接代理到全局 window.chatDialog
$alert: (...args) => window.chatDialog.alert(...args),
$confirm: (...args) => window.chatDialog.confirm(...args),
$prompt: (...args) => window.chatDialog.prompt(...args),
/** 切换好友关系(加好友 / 删好友) */
async toggleFriend() {
@@ -354,7 +355,7 @@
},
/** 踢出用户 */
async kickUser() {
const reason = prompt('踢出原因(可留空):', '违反聊天室规则');
const reason = await this.$prompt('踢出原因(可留空):', '违反聊天室规则', '踢出用户', '#cc4444');
if (reason === null) return;
try {
const res = await fetch('/command/kick', {
@@ -370,10 +371,10 @@
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
alert('网络异常');
this.$alert('网络异常', '错误', '#cc4444');
}
},
@@ -393,16 +394,16 @@
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
alert('网络异常');
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 警告用户 */
async warnUser() {
const reason = prompt('警告原因:', '请注意言行');
const reason = await this.$prompt('警告原因:', '请注意言行', '警告用户', '#f59e0b');
if (reason === null) return;
try {
const res = await fetch('/command/warn', {
@@ -418,17 +419,22 @@
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
alert('网络异常');
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 冻结用户 */
async freezeUser() {
if (!confirm('确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录!')) return;
const reason = prompt('冻结原因:', '严重违规');
const confirmed = await this.$confirm(
'确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录!',
'冻结账号',
'#cc4444'
);
if (!confirmed) return;
const reason = await this.$prompt('冻结原因:', '严重违规', '填写原因', '#cc4444');
if (reason === null) return;
try {
const res = await fetch('/command/freeze', {
@@ -444,10 +450,10 @@
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
alert('网络异常');
this.$alert('网络异常', '错误', '#cc4444');
}
},
@@ -464,10 +470,10 @@
this.whisperList = data.messages;
this.showWhispers = true;
} else {
alert(data.message);
this.$alert(data.message, '操作失败', '#cc4444');
}
} catch (e) {
alert('网络异常');
this.$alert('网络异常', '错误', '#cc4444');
}
},
@@ -488,10 +494,10 @@
this.announceText = '';
this.showAnnounce = false;
} else {
alert(data.message);
this.$alert(data.message, '操作失败', '#cc4444');
}
} catch (e) {
alert('网络异常');
this.$alert('网络异常', '错误', '#cc4444');
}
},
@@ -1010,10 +1016,10 @@
async send() {
if (this.sending) return;
const amt = parseInt(this.amount, 10);
if (!amt || amt <= 0) { alert('请输入有效金额'); return; }
if (!amt || amt <= 0) { window.chatDialog.alert('请输入有效金额', '提示', '#f59e0b'); return; }
const maxOnce = window.chatContext?.myMaxReward;
if (maxOnce === 0) { alert('你的职务没有奖励发放权限'); return; }
if (maxOnce > 0 && amt > maxOnce) { alert('超出单次上限 ' + maxOnce + ' 金币'); return; }
if (maxOnce === 0) { window.chatDialog.alert('你的职务没有奖励发放权限', '无权限', '#cc4444'); return; }
if (maxOnce > 0 && amt > maxOnce) { window.chatDialog.alert('超出单次上限 ' + maxOnce + ' 金币', '超出上限', '#cc4444'); return; }
this.sending = true;
try {
const res = await fetch(window.chatContext.rewardUrl, {
@@ -1040,18 +1046,18 @@
this.quota.recent_rewards.unshift({ target: this.targetUsername, amount: amt, created_at: mm + '-' + dd + ' ' + hh + ':' + mi });
if (this.quota.recent_rewards.length > 10) this.quota.recent_rewards.pop();
this.amount = '';
alert(data.message);
window.chatDialog.alert(data.message, '🎉 奖励发放成功', '#d97706');
} else {
alert(data.message || '发放失败');
window.chatDialog.alert(data.message || '发放失败', '操作失败', '#cc4444');
}
} catch { alert('网络异常,请稍后重试'); }
} catch { window.chatDialog.alert('网络异常,请稍后重试', '错误', '#cc4444'); }
this.sending = false;
}
}">
<div x-show="show" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9900;"
x-on:click.self="show = false">
<div x-show="show"
style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
style="display:none; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:520px; max-width:95vw; background:#fff; border-radius:16px;
box-shadow:0 20px 60px rgba(0,0,0,.25); overflow:hidden;">
{{-- 标题栏 --}}
@@ -1162,7 +1168,207 @@
const el = document.getElementById('reward-modal-container');
if (el) {
const data = Alpine.$data(el);
if (data) data.open(username);
if (data) {
data.open(username);
}
}
}
</script>
{{-- ═══════════ 好友系统通知监听 ═══════════ --}}
{{-- 监听好友 WebSocket 事件,与好友操作逻辑集中在同一文件维护 --}}
<script>
// ── 好友系统私有频道监听(仅本人可见) ────────────────
/**
* 监听当前用户的私有频道 `user.{id}`
* 收到 FriendAdded / FriendRemoved 事件时用弹窗通知。
* FriendAdded 居中大卡弹窗chatBanner 风格)
* FriendRemoved 右下角 Toast 通知
*/
function setupFriendNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupFriendNotification, 500);
return;
}
const myId = window.chatContext.userId;
window.Echo.private(`user.${myId}`)
.listen('.FriendAdded', (e) => {
showFriendBanner(e.from_username, e.has_added_back);
})
.listen('.FriendRemoved', (e) => {
if (e.had_added_back) {
window.chatToast.show({
title: '好友通知',
message: `<b>${e.from_username}</b> 已将你从好友列表移除。<br><span style="color:#6b7280; font-size:12px;">你的好友列表中仍保留对方,可点击同步移除。</span>`,
icon: '👥',
color: '#6b7280',
duration: 10000,
action: {
label: `🗑️ 同步移除 ${e.from_username}`,
onClick: async () => {
const url = `/friend/${encodeURIComponent(e.from_username)}/remove`;
const csrf = document.querySelector('meta[name="csrf-token"]')
?.content ?? '';
await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json'
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
}
},
});
} else {
window.chatToast.show({
title: '好友通知',
message: `<b>${e.from_username}</b> 已将你从他的好友列表移除。`,
icon: '👥',
color: '#9ca3af',
});
}
});
}
document.addEventListener('DOMContentLoaded', setupFriendNotification);
// ── BannerNotification通用大卡片通知监听 ──────────────────
/**
* 监听 BannerNotification 事件,渲染 chatBanner 大卡片。
* 支持私有用户频道(单推)和房间频道(全员推送)。
*
* 安全说明BannerNotification 仅由后端可信代码 broadcast
* 私有频道需鉴权presence 频道需加入房间,均须服务端验证身份。
*/
function setupBannerNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupBannerNotification, 500);
return;
}
const myId = window.chatContext.userId;
const roomId = window.chatContext.roomId;
// 监听私有用户频道(单独推给某人)
window.Echo.private(`user.${myId}`)
.listen('.BannerNotification', (e) => {
if (e.options && typeof e.options === 'object') {
window.chatBanner.show(e.options);
}
});
// 监听房间频道(推给房间所有人)
if (roomId) {
window.Echo.join(`room.${roomId}`)
.listen('.BannerNotification', (e) => {
if (e.options && typeof e.options === 'object') {
window.chatBanner.show(e.options);
}
});
}
}
document.addEventListener('DOMContentLoaded', setupBannerNotification);
/**
* 显示好友添加居中大卡弹窗(使用 chatBanner 公共组件)。
* 互相好友 绿色渐变 + 互为好友文案
* 单向添加 蓝绿渐变 + 提示回加 + [ 回加好友] 按钮
*
* @param {string} fromUsername 添加者用户名
* @param {boolean} hasAddedBack 接收方是否已将添加者加为好友
*/
function showFriendBanner(fromUsername, hasAddedBack) {
if (hasAddedBack) {
window.chatBanner.show({
id: 'friend-banner',
icon: '🎉💚🎉',
title: '好友通知',
name: fromUsername,
body: '将你加为好友了!',
sub: '<strong style="color:#a7f3d0;">你们现在互为好友 🎊</strong>',
gradient: ['#065f46', '#059669', '#10b981'],
titleColor: '#a7f3d0',
autoClose: 5000,
});
} else {
window.chatBanner.show({
id: 'friend-banner',
icon: '💚📩',
title: '好友申请',
name: fromUsername,
body: '将你加为好友了!',
sub: '但你还没有回加对方为好友',
gradient: ['#1e3a5f', '#1d4ed8', '#0891b2'],
titleColor: '#bae6fd',
autoClose: 0,
buttons: [{
label: ' 回加好友',
color: '#10b981',
onClick: async (btn, close) => {
await quickFriendAction('add', fromUsername, btn);
if (btn.textContent.startsWith('✅')) {
setTimeout(close, 1500);
}
},
},
{
label: '稍后再说',
color: 'rgba(255,255,255,0.15)',
onClick: (btn, close) => close(),
},
],
});
}
}
/**
* 聊天区悄悄话内嵌链接的快捷好友操作。
* 由后端生成的 onclick="quickFriendAction('add'/'remove', username, this)" 调用。
*
* @param {string} act 'add' | 'remove'
* @param {string} username 目标用户名
* @param {HTMLElement} el 被点击的 <a> 元素,用于更新显示状态
*/
window.quickFriendAction = async function(act, username, el) {
if (el.dataset.done) {
return;
}
el.dataset.done = '1';
el.textContent = '处理中…';
el.style.pointerEvents = 'none';
try {
const method = act === 'add' ? 'POST' : 'DELETE';
const url = `/friend/${encodeURIComponent(username)}/${act === 'add' ? 'add' : 'remove'}`;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
const data = await res.json();
if (data.status === 'success') {
el.textContent = act === 'add' ? '✅ 已回加' : '✅ 已移除';
el.style.color = '#16a34a';
el.style.textDecoration = 'none';
} else {
el.textContent = '❌ ' + (data.message || '操作失败');
el.style.color = '#cc4444';
}
} catch (e) {
el.textContent = '❌ 网络错误';
el.style.color = '#cc4444';
delete el.dataset.done;
el.style.pointerEvents = '';
}
};
</script>