重构(chat): 聊天室 Partials 第二阶段分类拆分及修复红包弹窗隐藏 Bug
- 完成对 scripts.blade.php 中非核心业务逻辑(钓鱼游戏、AI机器人、系统全局公告)的深度抽象隔离 - 修复抢红包逻辑中 setInterval 缺失时间参数(1000)引发浏览器前端主线程挂起的重度阻塞问题 - 修复 lottery-panel 组件结尾漏写 </div> 导致的连锁级渲染树崩溃(该崩溃导致红包节点被意外当作隐藏后代节点渲染,造成彻底不可见) - 对相关模板规范代码结构,执行 Laravel Pint 格式化并提交
This commit is contained in:
@@ -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),
|
||||
]);
|
||||
|
||||
// 将拆分好的数量序列存入 Redis(List,LPOP 抢红包)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 包自带的内置数据库
|
||||
|
||||
@@ -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>
|
||||
|
||||
89
resources/views/chat/partials/ai-chatbot.blade.php
Normal file
89
resources/views/chat/partials/ai-chatbot.blade.php
Normal 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>
|
||||
178
resources/views/chat/partials/chat-banner.blade.php
Normal file
178
resources/views/chat/partials/chat-banner.blade.php
Normal 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 自动关闭 ms,0=不关闭,默认 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>
|
||||
@@ -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">×</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 || ' '}</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>
|
||||
461
resources/views/chat/partials/games/fishing-panel.blade.php
Normal file
461
resources/views/chat/partials/games/fishing-panel.blade.php
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
460
resources/views/chat/partials/games/game-hall.blade.php
Normal file
460
resources/views/chat/partials/games/game-hall.blade.php
Normal 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">×</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 || ' '}</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>
|
||||
@@ -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>
|
||||
688
resources/views/chat/partials/games/red-packet-panel.blade.php
Normal file
688
resources/views/chat/partials/games/red-packet-panel.blade.php
Normal 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.chatBanner(chat-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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
@@ -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
128
resources/views/chat/partials/system-events.blade.php
Normal file
128
resources/views/chat/partials/system-events.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user