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

- 完成对 scripts.blade.php 中非核心业务逻辑(钓鱼游戏、AI机器人、系统全局公告)的深度抽象隔离
- 修复抢红包逻辑中 setInterval 缺失时间参数(1000)引发浏览器前端主线程挂起的重度阻塞问题
- 修复 lottery-panel 组件结尾漏写 </div> 导致的连锁级渲染树崩溃(该崩溃导致红包节点被意外当作隐藏后代节点渲染,造成彻底不可见)
- 对相关模板规范代码结构,执行 Laravel Pint 格式化并提交
This commit is contained in:
2026-03-09 11:30:11 +08:00
parent 28d9f9ee96
commit bfb1a3bca4
24 changed files with 2806 additions and 2601 deletions
@@ -0,0 +1,650 @@
{{--
文件功能:百家乐前台弹窗组件
聊天室内百家乐游戏面板:
- 监听 WebSocket baccarat.opened 事件触发弹窗
- 倒计时下注(大//豹子)
- 监听 baccarat.settled 展示骰子动画 + 结果 + 个人赔付
- 展示近10局历史趋势
--}}
{{-- 百家乐主面板 --}}
<div id="baccarat-panel" x-data="baccaratPanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9940;
display:flex; align-items:center; justify-content:center;">
<div
style="width:460px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 顶部标题 ─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px;
display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="color:#fff; font-weight:bold; font-size:14px;">🎲 百家乐</div>
<div style="color:rgba(255,255,255,.75); font-size:11px; margin-top:1px;">
<span x-text="'#' + roundId"></span>
</div>
</div>
{{-- 倒计时 --}}
<div x-show="phase === 'betting'" style="text-align:center;">
<div style="color:#fbbf24; font-size:28px; font-weight:900; line-height:1;" x-text="countdown">
</div>
<div style="color:rgba(255,255,255,.7); font-size:11px;">秒后截止</div>
</div>
{{-- 骰子结果 --}}
<div x-show="phase === 'settled'" style="display:none; text-align:center;">
<div style="font-size:24px;" x-text="diceEmoji"></div>
<div style="color:#fbbf24; font-size:12px; font-weight:bold; margin-top:2px;" x-text="resultLabel">
</div>
</div>
</div>
{{-- 进度条 --}}
<div x-show="phase === 'betting'" style="height:3px; background:rgba(255,255,255,.2);">
<div style="height:100%; background:#fbbf24; transition:width 1s linear;"
:style="'width:' + Math.max(0, (countdown / totalSeconds * 100)) + '%'"></div>
</div>
{{-- ─── 历史趋势 ─── --}}
<div
style="background:#f0f6ff; padding:7px 14px; display:flex; gap:5px; align-items:center;
flex-wrap:wrap; border-bottom:1px solid #d0e4f5;">
<span style="color:#99b0cc; font-size:11px; margin-right:2px;">近期</span>
<template x-for="h in history" :key="h.id">
<span
style="width:22px; height:22px; border-radius:50%; font-size:11px; font-weight:bold;
display:flex; align-items:center; justify-content:center; color:#fff;"
:style="h.result === 'big' ? 'background:#1d4ed8' :
h.result === 'small' ? 'background:#d97706' :
h.result === 'triple' ? 'background:#7c3aed' :
'background:#94a3b8'"
:title="'#' + h.id + ' ' + (h.result === 'big' ? '大' : h.result === 'small' ? '小' : h
.result === 'triple' ? '豹' : '☠')"
x-text="h.result === 'big' ? '大' : h.result === 'small' ? '小' : h.result === 'triple' ? '豹' : '☠'">
</span>
</template>
<span x-show="history.length === 0" style="color:#b0c4d8; font-size:11px;">暂无记录</span>
</div>
{{-- ─── 主体内容(白底) ─── --}}
<div style="background:#fff; padding:14px 16px;">
{{-- 押注阶段 --}}
<div x-show="phase === 'betting'">
{{-- 下注池统计 --}}
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:12px;">
<div
style="background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; padding:8px; text-align:center;">
<div style="color:#1d4ed8; font-size:11px; font-weight:bold;">押大</div>
<div style="color:#1e40af; font-weight:bold; font-size:13px;"
x-text="Number(totalBetBig).toLocaleString()"></div>
</div>
<div
style="background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:8px; text-align:center;">
<div style="color:#d97706; font-size:11px; font-weight:bold;">押小</div>
<div style="color:#b45309; font-weight:bold; font-size:13px;"
x-text="Number(totalBetSmall).toLocaleString()"></div>
</div>
<div
style="background:#f5f3ff; border:1px solid #ddd6fe; border-radius:8px; padding:8px; text-align:center;">
<div style="color:#7c3aed; font-size:11px; font-weight:bold;">押豹子</div>
<div style="color:#6d28d9; font-weight:bold; font-size:13px;"
x-text="Number(totalBetTriple).toLocaleString()"></div>
</div>
</div>
{{-- 已下注状态 --}}
<div x-show="myBet">
<div
style="background:#f0fdf4; border:1px solid #86efac; border-radius:10px;
padding:10px 14px; text-align:center; margin-bottom:10px;">
<div style="color:#16a34a; font-weight:bold; font-size:13px;">
已押注「<span x-text="betTypeLabel(myBetType)"></span>
<span x-text="Number(myBetAmount).toLocaleString()"></span> 金币
</div>
<div style="color:#86a896; font-size:11px; margin-top:3px;">等待开奖中…</div>
</div>
</div>
{{-- 下注表单 --}}
<div x-show="!myBet">
{{-- 押注选项 --}}
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:12px;">
{{-- --}}
<button x-on:click="selectedType='big'"
style="border-radius:10px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold; font-family:inherit;"
:style="selectedType === 'big'
?
'border:2px solid #1d4ed8; background:#1d4ed8; color:#fff; transform:scale(1.05); box-shadow:0 4px 14px rgba(29,78,216,.3);' :
'border:2px solid #bfdbfe; background:#eff6ff; color:#1d4ed8;'">
<div style="font-size:22px;">🔵</div>
<div style="font-size:13px; margin-top:4px;"></div>
<div style="font-size:10px; margin-top:2px; opacity:.75;">11~17 1:1</div>
</button>
{{-- --}}
<button x-on:click="selectedType='small'"
style="border-radius:10px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold; font-family:inherit;"
:style="selectedType === 'small'
?
'border:2px solid #d97706; background:#d97706; color:#fff; transform:scale(1.05); box-shadow:0 4px 14px rgba(217,119,6,.3);' :
'border:2px solid #fde68a; background:#fffbeb; color:#b45309;'">
<div style="font-size:22px;">🟡</div>
<div style="font-size:13px; margin-top:4px;"></div>
<div style="font-size:10px; margin-top:2px; opacity:.75;">4~10 1:1</div>
</button>
{{-- 豹子 --}}
<button x-on:click="selectedType='triple'"
style="border-radius:10px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold; font-family:inherit;"
:style="selectedType === 'triple'
?
'border:2px solid #7c3aed; background:#7c3aed; color:#fff; transform:scale(1.05); box-shadow:0 4px 14px rgba(124,58,237,.3);' :
'border:2px solid #ddd6fe; background:#f5f3ff; color:#7c3aed;'">
<div style="font-size:22px;">💥</div>
<div style="font-size:13px; margin-top:4px;">豹子</div>
<div style="font-size:10px; margin-top:2px; opacity:.75;">三同 1:24</div>
</button>
</div>
{{-- 快捷金额 --}}
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:6px; margin-bottom:10px;">
<template x-for="preset in [100, 500, 1000, 5000, 10000]" :key="preset">
<button x-on:click="betAmount = preset"
style="border-radius:20px; padding:8px 2px; font-size:13px; font-weight:bold;
cursor:pointer; transition:all .15s; font-family:inherit; text-align:center;"
:style="betAmount === preset ?
'background:#336699; color:#fff; border:none; box-shadow:0 3px 10px rgba(51,102,153,.35); transform:translateY(-1px);' :
'background:#fff; color:#336699; border:1.5px solid #c0d8ef;'"
x-text="preset >= 1000 ? (preset/1000)+'k' : preset">
</button>
</template>
</div>
{{-- 自定义金额 --}}
<input type="number" x-model.number="betAmount" min="100" placeholder="自定义金额"
style="width:100%; background:#f6faff; border:1.5px solid #d0e4f5;
border-radius:8px; padding:8px 12px; color:#225588; font-size:13px;
box-sizing:border-box; margin-bottom:10px;"
x-on:focus="$event.target.select()">
{{-- 下注按钮 --}}
<button x-on:click="submitBet()" :disabled="!selectedType || betAmount < 100 || submitting"
:style="(!selectedType || betAmount < 100 || submitting) ?
'display:block; width:100%; border:none; border-radius:12px; padding:13px 0; font-size:14px; font-weight:bold; cursor:not-allowed; transition:all .2s; background:#e0e8f0; color:#99a8b8; box-shadow:none; font-family:inherit;' :
'display:block; width:100%; border:none; border-radius:12px; padding:13px 0; font-size:14px; font-weight:bold; cursor:pointer; transition:all .2s; background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 4px 14px rgba(51,102,153,.3); font-family:inherit;'">
<span
x-text="submitting ? '提交中…' : (!selectedType ? '请先选择大/小/豹子' : '🎲 押注「' + betTypeLabel(selectedType) + '」 ' + Number(betAmount).toLocaleString() + ' 金币')"></span>
</button>
</div>
{{-- 规则提示 --}}
<div style="margin-top:10px; color:#99b0cc; font-size:10px; text-align:center;">
☠️ 3点或18点为庄家收割,全灭无退款。豹子优先于大小判断。
</div>
</div>
{{-- 等待开奖阶段 --}}
<div x-show="phase === 'waiting'" style="display:none; text-align:center; padding:20px 0;">
<div style="font-size:44px; animation:spin 1s linear infinite; display:inline-block;">🎲</div>
<div style="color:#5a8fc0; margin-top:10px; font-weight:bold;">正在摇骰子…</div>
</div>
{{-- 结算阶段 --}}
<div x-show="phase === 'settled'" style="display:none;">
{{-- 骰子展示 --}}
<div style="display:flex; justify-content:center; gap:10px; margin-bottom:16px;">
<template x-for="(d, i) in settledDice" :key="i">
<div style="width:56px; height:56px; border-radius:12px; font-weight:900;
display:flex; align-items:center; justify-content:center;
font-size:26px; animation:dice-pop .4s ease-out both;
color:#1e3a5f; background:linear-gradient(145deg,#ffffff,#e8f0fb);
border:1.5px solid #d0e4f5; box-shadow:0 4px 14px rgba(51,102,153,.12);"
:style="'animation-delay:' + (i * 0.18) + 's'" x-text="d">
</div>
</template>
</div>
{{-- 结果标签 --}}
<div style="text-align:center; margin-bottom:14px;">
<div style="font-size:24px; font-weight:900; letter-spacing:2px;"
:style="settledResult === 'big' ? 'color:#1d4ed8' :
settledResult === 'small' ? 'color:#d97706' :
settledResult === 'triple' ? 'color:#7c3aed' :
settledResult === 'kill' ? 'color:#dc2626' : 'color:#336699'"
x-text="resultLabel"></div>
<div style="color:#99b0cc; font-size:12px; margin-top:4px;"
x-text="'骰子总点数:' + settledTotal + ' 点'"></div>
</div>
{{-- 个人结果卡片 --}}
<div x-show="myBet">
{{-- 中奖 --}}
<div x-show="myWon"
style="border-radius:12px; overflow:hidden; margin-bottom:4px;
background:#f0fdf4; border:1px solid #86efac;">
<div style="padding:14px 16px; text-align:center;">
<div style="font-size:32px; margin-bottom:4px;">🎉</div>
<div style="color:#16a34a; font-size:18px; font-weight:900;">恭喜中奖!</div>
<div style="color:#15803d; font-size:24px; font-weight:bold; margin:6px 0;"
x-text="'+' + Number(myPayout).toLocaleString() + ' 💰'"></div>
<div style="color:#86a896; font-size:11px;"
x-text="'押「' + betTypeLabel(myBetType) + '」' + Number(myBetAmount).toLocaleString() + ' 金币 → 赢得 ' + Number(myPayout).toLocaleString() + ' 金币'">
</div>
</div>
</div>
{{-- 未中奖 --}}
<div x-show="!myWon"
style="border-radius:12px; overflow:hidden; margin-bottom:4px;
background:#fff5f5; border:1px solid #fecaca;">
<div style="padding:14px 16px; text-align:center;">
<div style="font-size:28px; margin-bottom:4px;">😔</div>
<div style="color:#dc2626; font-size:16px; font-weight:bold; margin-bottom:8px;">本局未中奖
</div>
<div
style="display:inline-flex; align-items:center; gap:8px;
background:#fef2f2; border-radius:20px; padding:5px 14px; border:1px solid #fecaca;">
<span style="color:#64748b; font-size:12px;">你押了</span>
<span style="font-weight:bold; font-size:13px; color:#d97706;"
x-text="betTypeLabel(myBetType)"></span>
<span style="color:#d0d5db; font-size:11px;">·</span>
<span style="color:#64748b; font-size:12px;">开了</span>
<span style="font-weight:bold; font-size:13px;"
:style="settledResult === 'big' ? 'color:#1d4ed8' :
settledResult === 'small' ? 'color:#d97706' :
settledResult === 'triple' ? 'color:#7c3aed' : 'color:#dc2626'"
x-text="resultLabel"></span>
</div>
<div style="color:#94a3b8; font-size:11px; margin-top:8px;"
x-text="'损失 ' + Number(myBetAmount).toLocaleString() + ' 金币'"></div>
</div>
</div>
</div>
{{-- 未下注但看结果 --}}
<div x-show="!myBet" style="text-align:center; color:#99b0cc; font-size:12px; padding:8px 0;">
本局未参与下注
</div>
</div>
</div>
{{-- 底部关闭 --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:12px 16px; display:flex; justify-content:center;">
<button x-on:click="close()"
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s; font-family:inherit;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
</div>
</div>
</div>
<script>
/**
* 百家乐骨骰悬浮按钮 Alpine 组件(拖动 + localStorage 位置持久化)
*/
function baccaratFab() {
const STORAGE_KEY = 'baccarat_fab_pos';
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return {
visible: false,
posX: saved?.x ?? 18,
posY: saved?.y ?? 90,
dragging: false,
_startX: 0,
_startY: 0,
_origX: 0,
_origY: 0,
_moved: false,
startDrag(e) {
this.dragging = true;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
e.currentTarget.setPointerCapture?.(e.pointerId);
},
onDrag(e) {
if (!this.dragging) return;
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
this.posX = Math.max(4, Math.min(window.innerWidth - 60, this._origX - dx));
this.posY = Math.max(4, Math.min(window.innerHeight - 60, this._origY + dy));
},
endDrag(e) {
if (!this.dragging) return;
this.dragging = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify({
x: this.posX,
y: this.posY
}));
if (!this._moved) this.openPanel();
},
openPanel() {
const panel = document.getElementById('baccarat-panel');
if (!panel) return;
const p = Alpine.$data(panel);
p.show = true;
if (p.phase === 'betting' && p.countdown > 0 && !p.countdownTimer) {
p.startCountdown();
}
},
};
}
</script>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes dice-pop {
0% {
transform: scale(0) rotate(-20deg);
opacity: 0;
}
70% {
transform: scale(1.15) rotate(5deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
@keyframes pulse-fab {
0%,
100% {
box-shadow: 0 4px 20px rgba(51, 102, 153, .4);
}
50% {
box-shadow: 0 4px 30px rgba(51, 102, 153, .8);
}
}
</style>
<script>
/**
* 百家乐游戏面板 Alpine 组件
*/
function baccaratPanel() {
return {
show: false,
phase: 'betting', // betting | waiting | settled
roundId: null,
totalSeconds: 60,
countdown: 60,
countdownTimer: null,
// 下注池统计
totalBetBig: 0,
totalBetSmall: 0,
totalBetTriple: 0,
// 本人下注
myBet: false,
myBetType: '',
myBetAmount: 0,
// 下注表单
selectedType: '',
betAmount: 100,
submitting: false,
// 结算结果
settledDice: [],
settledTotal: 0,
settledResult: '',
resultLabel: '',
diceEmoji: '',
myWon: false,
myPayout: 0,
// 历史记录
history: [],
/**
* 开局:填充局次数据并开始倒计时
*/
openRound(data) {
this.phase = 'betting';
this.roundId = data.round_id;
this.countdown = data.bet_seconds || 60;
this.totalSeconds = this.countdown;
this.myBet = false;
this.myBetType = '';
this.myBetAmount = 0;
this.settledDice = [];
this.selectedType = '';
this.betAmount = 100;
this.show = true;
this.loadCurrentRound();
this.startCountdown();
this.updateFab(true);
},
/**
* 从接口获取当前局的状态(我的下注、投注池)
*/
async loadCurrentRound() {
try {
const res = await fetch('/baccarat/current');
const data = await res.json();
if (data.round) {
this.totalBetBig = data.round.total_bet_big;
this.totalBetSmall = data.round.total_bet_small;
this.totalBetTriple = data.round.total_bet_triple;
if (data.round.my_bet) {
this.myBet = true;
this.myBetType = data.round.my_bet.bet_type;
this.myBetAmount = data.round.my_bet.amount;
}
}
} catch {}
},
/**
* 启动倒计时
*/
startCountdown() {
clearInterval(this.countdownTimer);
this.countdownTimer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
clearInterval(this.countdownTimer);
this.phase = 'waiting';
}
}, 1000);
},
/**
* 提交下注
*/
async submitBet() {
if (!this.selectedType || this.betAmount < 100 || this.submitting) return;
this.submitting = true;
try {
const res = await fetch('/baccarat/bet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
body: JSON.stringify({
round_id: this.roundId,
bet_type: this.selectedType,
amount: this.betAmount,
}),
});
const data = await res.json();
if (data.ok) {
this.myBet = true;
this.myBetType = data.bet_type;
this.myBetAmount = data.amount;
window.chatDialog?.alert(data.message, '下注成功', '#336699');
} else {
window.chatDialog?.alert(data.message || '下注失败', '提示', '#ef4444');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
}
this.submitting = false;
},
/**
* 显示开奖结果动画
*/
showResult(data) {
clearInterval(this.countdownTimer);
this.settledDice = data.dice;
this.settledTotal = data.total_points;
this.settledResult = data.result;
this.resultLabel = data.result_label;
this.phase = 'settled';
this.show = true;
// 判断本人是否中奖(从后端拿到的 result 与我的下注 type 比较)
if (this.myBet && this.myBetType === data.result && data.result !== 'kill') {
this.myWon = true;
// 简单计算前端显示赔付(实际赔付以后端为准)
const payoutRate = data.result === 'triple' ? 24 : 1;
this.myPayout = this.myBetAmount * (payoutRate + 1);
} else {
this.myWon = false;
this.myPayout = 0;
}
this.updateFab(false);
this.loadHistory();
},
/**
* 加载历史趋势
*/
async loadHistory() {
try {
const res = await fetch('/baccarat/history');
const data = await res.json();
this.history = (data.history || []).reverse();
} catch {}
},
/**
* 更新悬浮按钮显示状态
*/
updateFab(visible) {
const fab = document.getElementById('baccarat-fab');
if (fab) Alpine.$data(fab).visible = visible;
},
/**
* 关闭面板
*/
close() {
this.show = false;
if (this.phase === 'betting') {
this.updateFab(true); // 还在下注阶段时保留悬浮按钮
}
},
/**
* 押注类型中文标签
*/
betTypeLabel(type) {
return {
big: '大',
small: '小',
triple: '豹子'
} [type] || '';
},
};
}
// ─── WebSocket 监听 ──────────────────────────────────────────────
/** 收到开局事件:弹出押注面板 */
window.addEventListener('chat:baccarat.opened', (e) => {
const panel = document.getElementById('baccarat-panel');
if (panel) Alpine.$data(panel).openRound(e.detail);
});
/** 收到结算事件:展示骰子动画和结果 */
window.addEventListener('chat:baccarat.settled', (e) => {
const panel = document.getElementById('baccarat-panel');
if (panel) Alpine.$data(panel).showResult(e.detail);
});
/** 页面加载时:检查是否有进行中的局,有则自动恢复面板 */
document.addEventListener('DOMContentLoaded', async () => {
try {
// 先加载历史趋势
const histRes = await fetch('/baccarat/history');
const histData = await histRes.json();
const panel = document.getElementById('baccarat-panel');
if (panel) {
Alpine.$data(panel).history = (histData.history || []).reverse();
}
// 再检查是否有正在进行的局
const curRes = await fetch('/baccarat/current');
const curData = await curRes.json();
if (curData.round && panel) {
const round = curData.round;
const seconds = round.seconds_left || 0;
const panelData = Alpine.$data(panel);
if (seconds > 0) {
// 有进行中的局且还在押注时间内 → 恢复押注面板
panelData.phase = 'betting';
panelData.roundId = round.id;
panelData.totalSeconds = 60; // 服务端配置的窗口
panelData.countdown = seconds;
panelData.totalBetBig = round.total_bet_big;
panelData.totalBetSmall = round.total_bet_small;
panelData.totalBetTriple = round.total_bet_triple;
if (round.my_bet) {
panelData.myBet = true;
panelData.myBetType = round.my_bet.bet_type;
panelData.myBetAmount = round.my_bet.amount;
}
// 只显示悬浮按钮,不自动弹出全屏(避免打扰刚进入的用户)
panelData.updateFab(true);
}
}
} catch (e) {
console.warn('[百家乐] 初始化失败', e);
}
});
</script>
@@ -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>
@@ -0,0 +1,378 @@
{{--
文件功能:神秘占卜前台弹窗组件
聊天室内神秘占卜面板:
- 点击悬浮 FAB 打开面板
- 展示今日签文(免费次数 / 付费次数)
- 卦象摇动动画 + 签文翻转展示
- 展示近20条历史记录
--}}
{{-- ─── 神秘占卜主面板 ─── --}}
<div id="fortune-panel" x-data="fortunePanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9942;
display:flex; align-items:center; justify-content:center;">
<div
style="width:420px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 顶部标题 ─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; display:flex; align-items:center; gap:10px;">
<div style="font-size:14px; font-weight:bold; color:#fff; flex:1;">🔮 神秘占卜</div>
<div style="font-size:11px; color:rgba(255,255,255,.75);">
今日免费 <span x-text="freeCount"></span> 次,已用 <span x-text="freeUsed"></span>
</div>
<span onclick="Alpine.$data(document.getElementById('fortune-panel')).show = false"
style="cursor:pointer; font-size:18px; color:rgba(255,255,255,.8); line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 主体内容 ─── --}}
<div style="background:#fff; padding:14px 16px;">
{{-- Tab 切换 --}}
<div style="display:flex; gap:6px; margin-bottom:16px;">
<button @click="activeTab = 'tell'"
:style="activeTab === 'tell'
?
'flex:1; border:none; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#336699; color:#fff; box-shadow:0 2px 6px rgba(51,102,153,.25);' :
'flex:1; border:1px solid #d0e4f5; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#f6faff; color:#5a8fc0;'">
🔮 今日占卜
</button>
<button @click="activeTab = 'history'; loadHistory()"
:style="activeTab === 'history'
?
'flex:1; border:none; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#336699; color:#fff; box-shadow:0 2px 6px rgba(51,102,153,.25);' :
'flex:1; border:1px solid #d0e4f5; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#f6faff; color:#5a8fc0;'">
📜 历史记录
</button>
</div>
{{-- ── 占卜 Tab ── --}}
<div x-show="activeTab === 'tell'">
{{-- 占卜动画区 --}}
<div style="text-align:center; padding:10px 0 16px;">
{{-- 未占卜:摇卦动画 --}}
<div x-show="!resultGrade">
<div style="font-size:72px; display:inline-block;"
:style="shaking ? 'animation:fortune-shake .5s ease-in-out;' :
'animation:float-orb 3s ease-in-out infinite;'">
🔮
</div>
<div style="color:#888; font-size:12px; margin-top:8px;">
<span x-show="hasFreeLeft">点击下方按钮,开启今日占卜</span>
<span x-show="!hasFreeLeft">免费次数已用完,可付费继续占卜</span>
</div>
</div>
{{-- 已占卜:展示签文 --}}
<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">
{{-- 签文等级徽章 --}}
<div style="display:inline-block; padding:4px 16px; border-radius:20px; font-weight:900;
font-size:14px; letter-spacing:2px; margin-bottom:12px; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.3);"
:style="'background:' + resultColor + '; border:1px solid rgba(0,0,0,.1);'"
x-text="resultLabel"></div>
{{-- 签文卡片(白底+左侧彩色装饰条) --}}
<div style="border-radius:6px; padding:12px 16px; margin:0 4px;
border:1px solid #d0e4f5; background:#f6faff; position:relative;"
:style="'border-left:4px solid ' + resultColor + ';'">
<div style="color:#225588; font-size:13px; line-height:1.8; text-align:center; font-style:italic;"
x-text="'「' + resultText + '」'"></div>
</div>
{{-- 当日加成 --}}
<div x-show="resultBuff"
style="margin:10px 8px 0; padding:8px 14px; border-radius:10px;
background:#f0f6ff; color:#336699; font-size:12px; text-align:left; border:1px solid #d0e4f5;"
x-text="resultBuff"></div>
</div>
</div>
{{-- 已有今日签文(只展示最新的,可再次占卜) --}}
<div x-show="todayLatest && !resultGrade"
style="border-radius:6px; padding:10px 12px; background:#f6faff;
border:1px solid #d0e4f5; margin-bottom:10px;">
<div style="color:#336699; font-size:11px; margin-bottom:6px; font-weight:bold;">今日最新签文</div>
<div style="display:flex; align-items:center; gap:6px;">
{{-- 签级标签:改为左彩色竖条 + 圆角徽章文字,不用纯色背景 --}}
<span
style="padding:2px 10px; border-radius:20px; font-size:11px; font-weight:bold; border:1.5px solid;"
:style="todayLatest
?
'color:' + todayLatest.grade_color + '; border-color:' + todayLatest.grade_color +
'; background: transparent;' :
'display:none'"
x-text="todayLatest?.grade_label"></span>
<span style="color:#225588; font-size:12px; flex:1;" x-text="todayLatest?.text"></span>
</div>
<div x-show="todayLatest?.buff_desc" style="color:#888; font-size:11px; margin-top:3px;"
x-text="todayLatest?.buff_desc"></div>
</div>
{{-- 占卜按钮 --}}
<div x-show="!resultGrade">
<button @click="doFortune()" :disabled="loading"
:style="loading
?
'display:block; width:100%; border:none; border-radius:12px; padding:12px 0; font-size:14px; font-weight:bold; cursor:not-allowed; transition:all .2s; background:#e0e8f0; color:#99a8b8; box-shadow:none;' :
hasFreeLeft ?
'display:block; width:100%; border:none; border-radius:12px; padding:12px 0; font-size:14px; font-weight:bold; cursor:pointer; transition:all .2s; background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 4px 12px rgba(51,102,153,.25);' :
'display:block; width:100%; border:none; border-radius:12px; padding:13px 0; font-size:15px; font-weight:bold; cursor:pointer; transition:all .2s; background:linear-gradient(135deg,#1e4d8c,#336699); color:#fff; box-shadow:0 4px 14px rgba(30,77,140,.3); letter-spacing:0.5px;'">
<span
x-text="loading ? '占卜中…' : (hasFreeLeft ? '🔮 免费占卜' : '🔮 付费占卜(' + extraCost + ' 金币)')"></span>
</button>
</div>
{{-- 再占一卦按钮 --}}
<div x-show="resultGrade" style="display:flex; gap:10px;">
<button @click="resultGrade = ''; resultText = ''; resultBuff = null"
style="flex:1; border:1px solid #b0d0ee; border-radius:12px; padding:12px 0;
background:#f0f6ff; color:#336699;
font-size:14px; font-weight:bold; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
返回
</button>
<button @click="resultGrade = ''; resultText = ''; resultBuff = null; doFortune()"
:disabled="loading"
style="flex:2; border:none; border-radius:12px; padding:12px 0;
background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 4px 12px rgba(51,102,153,.2);
font-size:14px; font-weight:bold; cursor:pointer; transition:all .15s;"
x-text="hasFreeLeft ? '🔮 再占一卦(免费)' : '🔮 再占一卦(' + extraCost + ' 金币)'"></button>
</div>
</div>
{{-- ── 历史 Tab ── --}}
<div x-show="activeTab === 'history'" style="display:none;">
<div style="max-height:280px; overflow-y:auto; display:flex; flex-direction:column; gap:6px;">
<template x-for="(log, i) in historyLogs" :key="i">
<div
style="border-radius:6px; padding:8px 10px; background:#f6faff;
border:1px solid #d0e4f5;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
<span
style="padding:1px 8px; border-radius:20px; font-size:10px; font-weight:bold; color:#fff;"
:style="'background:' + log.grade_color + ';'" x-text="log.grade_label"></span>
<span style="color:#aaa; font-size:10px;" x-text="log.date + ' ' + log.time"></span>
<span x-show="log.cost > 0" style="color:#b45309; font-size:10px; margin-left:auto;"
x-text="'花费 ' + log.cost + '金'"></span>
</div>
<div style="color:#225588; font-size:12px; line-height:1.6;" x-text="log.text"></div>
<div x-show="log.buff_desc" style="color:#888; font-size:11px; margin-top:3px;"
x-text="log.buff_desc"></div>
</div>
</template>
<div x-show="historyLogs.length === 0"
style="text-align:center; color:#aaa; font-size:12px; padding:20px 0;">
尚无占卜记录
</div>
</div>
</div>
</div>
{{-- ─── 底部关闭 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:14px 16px; display:flex; justify-content:center;">
<button @click="show = false"
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
</div>
</div>
</div>
<style>
@keyframes float-orb {
0%,
100% {
transform: translateY(0) scale(1);
filter: drop-shadow(0 0 12px rgba(168, 85, 247, .6));
}
50% {
transform: translateY(-8px) scale(1.05);
filter: drop-shadow(0 0 24px rgba(168, 85, 247, .9));
}
}
@keyframes fortune-shake {
0%,
100% {
transform: rotate(0deg) scale(1.1);
}
20% {
transform: rotate(-15deg) scale(1.15);
}
40% {
transform: rotate(12deg) scale(1.15);
}
60% {
transform: rotate(-8deg) scale(1.12);
}
80% {
transform: rotate(5deg) scale(1.1);
}
}
@keyframes pulse-fortune {
0%,
100% {
box-shadow: 0 4px 20px rgba(168, 85, 247, .5);
}
50% {
box-shadow: 0 4px 30px rgba(168, 85, 247, .9);
}
}
</style>
<script>
/**
* 神秘占卜主面板 Alpine 组件
*/
function fortunePanel() {
return {
show: false,
activeTab: 'tell',
loading: false,
shaking: false,
// 游戏配置
freeCount: 1,
freeUsed: 0,
hasFreeLeft: true,
extraCost: 500,
// 今日最新签文(若已占卜过)
todayLatest: null,
// 本次占卜结果
resultGrade: '',
resultLabel: '',
resultColor: '#a855f7',
resultText: '',
resultBuff: null,
// 历史记录
historyLogs: [],
/**
* 加载今日占卜状态
*/
async loadTodayStatus() {
try {
const res = await fetch('/fortune/today');
const data = await res.json();
if (!data.enabled) return;
this.freeCount = data.free_count || 1;
this.freeUsed = data.free_used || 0;
this.hasFreeLeft = data.has_free_left ?? true;
this.extraCost = data.extra_cost || 500;
this.todayLatest = data.latest || null;
} catch {}
},
/**
* 执行占卜
*/
async doFortune() {
if (this.loading) return;
this.loading = true;
this.shaking = true;
// 摇卦动画
await new Promise(r => setTimeout(r, 600));
this.shaking = false;
try {
const res = await fetch('/fortune/tell', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
});
const data = await res.json();
if (data.ok) {
this.resultGrade = data.grade;
this.resultLabel = data.grade_label;
this.resultColor = data.grade_color;
this.resultText = data.text;
this.resultBuff = data.buff_desc;
// 更新今日状态
if (data.is_free) {
this.freeUsed++;
this.hasFreeLeft = this.freeUsed < this.freeCount;
}
} else {
window.chatDialog?.alert(data.message || '占卜失败', '提示', '#ef4444');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
}
this.loading = false;
},
/**
* 加载历史记录
*/
async loadHistory() {
if (this.historyLogs.length > 0) return; // 已加载过则不重复请求
try {
const res = await fetch('/fortune/history');
const data = await res.json();
this.historyLogs = data.history || [];
} catch {}
},
};
}
/** 页面加载时:检查游戏是否开启,若开启则初始化面板数据 */
document.addEventListener('DOMContentLoaded', async () => {
try {
const res = await fetch('/fortune/today');
const data = await res.json();
if (data.enabled) {
const panel = document.getElementById('fortune-panel');
if (panel) {
const pd = Alpine.$data(panel);
pd.freeCount = data.free_count || 1;
pd.freeUsed = data.free_used || 0;
pd.hasFreeLeft = data.has_free_left ?? true;
pd.extraCost = data.extra_cost || 500;
pd.todayLatest = data.latest || null;
}
}
} catch (e) {
console.warn('[神秘占卜] 初始化失败', e);
}
});
</script>
@@ -0,0 +1,460 @@
{{--
文件功能:娱乐游戏大厅弹窗组件
点击工具栏「娱乐」按钮后弹出,展示所有已开启的游戏:
- 百家乐:当前场次状态 + 倒计时 + 直接参与按钮
- 老虎机:今日限额余量 + 直接打开按钮
- 神秘箱子:已投放数量 + 直接打开按钮
- 赛马竞猜:当前场次状态 + 参与按钮
- 神秘占卜:今日占卜次数 + 直接打开按钮
- 钓鱼:状态 + 打开按钮
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ─── 服务端注入各游戏开关状态(避免前端额外请求)─── --}}
@php
$gameEnabled = [
'baccarat' => \App\Models\GameConfig::isEnabled('baccarat'),
'slot_machine' => \App\Models\GameConfig::isEnabled('slot_machine'),
'mystery_box' => \App\Models\GameConfig::isEnabled('mystery_box'),
'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'),
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
];
@endphp
<script>
/** 后台游戏开关状态(Blade 服务端注入,1分钟缓存) */
window.GAME_ENABLED = @json($gameEnabled);
</script>
{{-- ═══════════ 游戏大厅弹窗遮罩 ═══════════ --}}
<div id="game-hall-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
z-index:9998; justify-content:center; align-items:center;">
<div id="game-hall-inner"
style="width:680px; max-width:96vw; max-height:88vh; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); display:flex; flex-direction:column;
background:#fff; font-family:'Microsoft YaHei',SimSun,sans-serif;">
{{-- ─── 标题栏(与商店弹窗同风格)─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;
padding:10px 16px; display:flex; align-items:center; gap:10px; flex-shrink:0;">
<div style="font-size:14px; font-weight:bold; flex:1;">🎮 娱乐大厅</div>
<div
style="font-size:12px; color:#d0e8ff; display:flex; align-items:center; gap:3px;
background:rgba(0,0,0,.2); padding:2px 8px; border-radius:10px;">
💰 <strong id="game-hall-jjb" style="color:#ffe082; font-size:13px;">--</strong> 金币
</div>
<span onclick="closeGameHall()"
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 游戏卡片网格 ─── --}}
<div style="flex:1; overflow-y:auto; background:#f6faff; padding:12px;">
{{-- 加载状态 --}}
<div id="game-hall-loading" style="text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;"></div>
加载游戏状态中…
</div>
{{-- 游戏卡片容器 --}}
<div id="game-hall-cards" style="display:none; grid-template-columns:1fr 1fr; gap:10px;">
</div>
{{-- 全部未开启提示 --}}
<div id="game-hall-empty"
style="display:none; text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;">🔒</div>
暂无开启的游戏,请联系管理员
</div>
</div>
{{-- ─── 底部 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:8px 16px;
display:flex; justify-content:center; flex-shrink:0;">
<button onclick="closeGameHall()"
style="padding:5px 24px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:4px;
font-size:12px; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">关闭</button>
</div>
</div>
</div>
<script>
/**
* 游戏大厅模块(使用 IIFE 隔离全局作用域,防止 const 重复初始化导致脚本块失败)
*/
(function() {
/** 游戏大厅配置定义(ID → 展示配置) */
const GAME_HALL_GAMES = [{
id: 'baccarat',
name: '🎲 百家乐',
desc: '猜骰子大小,1:1 赔率,豹子 1:24',
accentColor: '#336699',
fetchUrl: '/baccarat/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('baccarat-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.round) return {
badge: '⏸ 等待开局',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下局即将开始,稍后再来'
};
const r = data.round;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number((r.total_bet_big||0)+(r.total_bet_small||0)+(r.total_bet_triple||0)).toLocaleString()} 金`
};
}
return {
badge: '⏳ 开奖中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '正在摇骰子…'
};
},
btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情',
},
{
id: 'slot_machine',
name: '🎰 老虎机',
desc: '每日限额旋转,中奖即时到账',
accentColor: '#0891b2',
fetchUrl: null,
openFn: () => {
closeGameHall();
const panel = document.getElementById('slot-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: () => ({
badge: '✅ 随时可玩',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '每日限额抽奖,旋转即可'
}),
btnLabel: () => '🎰 开始旋转',
},
{
id: 'mystery_box',
name: '📦 神秘箱子',
desc: '管理员随机投放,抢到即开奖',
accentColor: '#b45309',
fetchUrl: '/mystery-box/status',
openFn: () => {
closeGameHall();
window.dispatchEvent(new CustomEvent('open-mystery-box'));
},
renderStatus: (data) => {
const count = data?.available_count ?? 0;
return count > 0 ? {
badge: `🎁 ${count} 个待领`,
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '箱子已投放!快去领取'
} : {
badge: '📭 暂无箱子',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '等待管理员投放'
};
},
btnLabel: (data) => (data?.available_count ?? 0) > 0 ? '🎁 立即领取' : '📭 等待投放',
},
{
id: 'horse_racing',
name: '🐎 赛马竞猜',
desc: '彩池制赛马,押注马匹赢取奖金',
accentColor: '#336699',
fetchUrl: '/horse-race/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openFromHall();
},
renderStatus: (data) => {
if (!data?.race) return {
badge: '⏸ 等待开赛',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场赛马即将开始'
};
const r = data.race;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number(r.total_pool || 0).toLocaleString()} 金`
};
}
if (r.status === 'running') {
return {
badge: '🏇 跑马中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '比赛进行中…'
};
}
return {
badge: '🏆 已结算',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场即将开始'
};
},
btnLabel: (data) => data?.race?.status === 'betting' ? '🐎 立即押注' : '📊 查看赛况',
},
{
id: 'fortune_telling',
name: '🔮 神秘占卜',
desc: '每日签文,开启今日运势加成',
accentColor: '#6d28d9',
fetchUrl: '/fortune/today',
openFn: () => {
closeGameHall();
const panel = document.getElementById('fortune-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.enabled) return {
badge: '🔒 未开启',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '此游戏暂未开启'
};
const used = data.free_used ?? 0;
const total = data.free_count ?? 1;
return data.has_free_left ? {
badge: '✨ 免费可占',
badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd',
detail: `今日已占 ${used}/${total} 次,还有免费次数`
} : {
badge: '💰 付费可占',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: `今日免费次数已用完(${data.extra_cost} 金/次)`
};
},
btnLabel: (data) => data?.has_free_left ? '🔮 免费占卜' : '🔮 付费占卜',
},
{
id: 'fishing',
name: '🎣 钓鱼',
desc: '消耗鱼饵钓取金币和道具。背包需有鱼饵才能出竿。',
accentColor: '#0d9488',
fetchUrl: null,
openFn: () => {
closeGameHall();
// 直接触发钓鱼,无需手动输入指令
if (typeof startFishing === 'function') {
startFishing();
}
},
renderStatus: () => ({
badge: '🎣 随时可钓',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '① 点击发言框上方【🎣 钓鱼】按钮 → ② 等待浮漂出现 → ③ 看到 🪝 后立刻点击收竿!'
}),
btnLabel: () => '🎣 去钓鱼',
},
{
id: 'lottery',
name: '🎟️ 双色球',
desc: '每日20:00开奖,选3红1蓝,按奖池比例派奖,无一等奖滚存累积',
accentColor: '#dc2626',
fetchUrl: '/lottery/current',
openFn: () => {
closeGameHall();
if (typeof openLotteryPanel === 'function') openLotteryPanel();
},
renderStatus: (data) => {
if (!data?.issue) return {
badge: '⏸ 等待开期',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '暂无进行中期次'
};
const iss = data.issue;
const pool = Number(iss.pool_amount || 0).toLocaleString();
if (data.is_open) {
const h = Math.floor(iss.seconds_left / 3600);
const m = Math.floor((iss.seconds_left % 3600) / 60);
const timeStr = h > 0 ? `${h}h ${m}m` : `${m}m`;
return {
badge: iss.is_super_issue ? '🎊 超级期购票中' : '🟢 购票中',
badgeStyle: 'background:#fef2f2; color:#b91c1c; border:1px solid #fca5a5',
detail: `💰 奖池 ${pool} 金 | 距开奖 ${timeStr} | 第 ${iss.issue_no} 期`
};
}
if (iss.status === 'settled') return {
badge: '✅ 已开奖',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '本期已开奖,下期购票中'
};
return {
badge: '🔴 已停售',
badgeStyle: 'background:#fee2e2; color:#b91c1c; border:1px solid #fecaca',
detail: `💰 奖池 ${pool} 金 | 等待开奖中…`
};
},
btnLabel: (data) => data?.is_open ? '🎟️ 立即购票' : '📊 查看结果',
},
];
/**
* 打开游戏大厅弹窗,加载各游戏状态
*/
window.openGameHall = async function() {
const modal = document.getElementById('game-hall-modal');
if (!modal) {
console.error('[游戏大厅] game-hall-modal 元素不存在,请检查模板加载');
return;
}
modal.style.display = 'flex';
document.getElementById('game-hall-loading').style.display = 'block';
document.getElementById('game-hall-cards').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'none';
const jjbEl = document.getElementById('game-hall-jjb');
if (jjbEl && window.chatContext?.userJjb !== undefined) {
jjbEl.textContent = Number(window.chatContext.userJjb).toLocaleString();
}
// 每次打开均实时拉取后台开关状态(避免页面不刷新时开关不同步)
let enabledMap = window.GAME_ENABLED ?? {};
try {
const r = await fetch('/games/enabled', {
headers: {
'Accept': 'application/json'
}
});
if (r.ok) enabledMap = await r.json();
} catch {
/* 网络异常时降级使用页面注入值 */
}
// 过滤出后台已开启的游戏
const enabledGames = GAME_HALL_GAMES.filter(g => enabledMap[g.id] !== false);
// 并行请求有状态接口的游戏
const statuses = {};
await Promise.all(
enabledGames.filter(g => g.fetchUrl).map(async g => {
try {
const res = await fetch(g.fetchUrl);
statuses[g.id] = await res.json();
} catch {
statuses[g.id] = null;
}
})
);
try {
renderGameCards(enabledGames, statuses);
} catch (err) {
console.error('[游戏大厅] 渲染游戏卡片失败:', err);
document.getElementById('game-hall-loading').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'block';
}
};
/**
* 关闭游戏大厅弹窗
*/
window.closeGameHall = function() {
document.getElementById('game-hall-modal').style.display = 'none';
};
/**
* 渲染所有游戏卡片(海军蓝风格)
*
* @param {Array} games 已过滤的游戏配置列表
* @param {Object} statuses 各游戏的 API 返回数据
*/
function renderGameCards(games, statuses) {
const container = document.getElementById('game-hall-cards');
container.innerHTML = '';
if (games.length === 0) {
document.getElementById('game-hall-loading').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'block';
return;
}
games.forEach(game => {
try {
const data = statuses[game.id] ?? null;
const status = game.renderStatus ? game.renderStatus(data) : {
badge: '✅ 可用',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: ''
};
const btnLabel = game.btnLabel ? game.btnLabel(data) : '🎮 进入';
const card = document.createElement('div');
card.style.cssText = `
background:#fff;
border:1px solid #d0e4f5;
border-left:4px solid ${game.accentColor};
border-radius:6px; padding:12px 14px;
cursor:default; transition:border-color .2s, box-shadow .2s;
display:flex; flex-direction:column; gap:8px;
`;
card.innerHTML = `
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
<div style="flex:1;">
<div style="color:#225588; font-weight:bold; font-size:13px; margin-bottom:3px;">${game.name}</div>
<div style="color:#666; font-size:11px; line-height:1.4;">${game.desc}</div>
</div>
<span style="padding:2px 8px; border-radius:10px; font-size:10px; font-weight:bold; white-space:nowrap; ${status.badgeStyle}">
${status.badge}
</span>
</div>
<div style="color:#888; font-size:10px; line-height:1.4; min-height:14px; border-top:1px dashed #e0ecf8; padding-top:6px;">${status.detail || '&nbsp;'}</div>
<button
style="width:100%; border:none; border-radius:4px; padding:7px 8px; font-size:12px; font-weight:bold;
cursor:pointer; color:#fff; transition:opacity .15s;
background:linear-gradient(135deg,${game.accentColor},${game.accentColor}cc);"
onmouseover="this.style.opacity='.85'"
onmouseout="this.style.opacity='1'">
${btnLabel}
</button>
`;
card.querySelector('button').addEventListener('click', (e) => {
e.stopPropagation();
game.openFn();
});
card.addEventListener('mouseenter', () => {
card.style.borderColor = game.accentColor;
card.style.boxShadow = `0 2px 8px rgba(51,102,153,.18)`;
});
card.addEventListener('mouseleave', () => {
card.style.borderColor = '#d0e4f5';
card.style.borderLeftColor = game.accentColor;
card.style.boxShadow = '';
});
container.appendChild(card);
} catch (err) {
// 单个游戏卡片渲染失败不影响其他游戏展示
console.warn(`[游戏大厅] 游戏 ${game.id} 卡片渲染失败:`, err);
}
});
document.getElementById('game-hall-loading').style.display = 'none';
container.style.display = 'grid';
}
// 点击遮罩关闭弹窗
document.getElementById('game-hall-modal').addEventListener('click', function(e) {
if (e.target === this) closeGameHall();
});
})(); // end IIFE
</script>
@@ -0,0 +1,677 @@
{{--
文件功能:赛马竞猜前台弹窗组件
聊天室内赛马竞猜游戏面板:
- 监听 WebSocket horse.opened 事件触发弹窗
- 展示参赛马匹列表和实时注池赔率
- 倒计时押注后进入跑马阶段(动态进度条)
- 监听 horse.progress 更新赛道动画
- 监听 horse.settled 展示结果 + 个人赔付
- 展示近10场历史趋势
--}}
{{-- ─── 赛马主面板 ─── --}}
<div id="horse-race-panel" x-data="horseRacePanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9941;
display:flex; align-items:center; justify-content:center;">
<div
style="width:500px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 标题栏(海军蓝风格)─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; display:flex; align-items:center; gap:10px;">
<div style="font-size:14px; font-weight:bold; color:#fff; flex:1;">
🐎 赛马竞猜
<span style="font-size:11px; font-weight:normal; color:rgba(255,255,255,.6); margin-left:4px;"
x-text="raceId ? '#' + raceId + ' 场' : ''"></span>
</div>
{{-- 倒计时(押注阶段) --}}
<div x-show="phase === 'betting'"
style="font-size:12px; color:#fff; background:rgba(255,255,255,.2); padding:4px 12px; border-radius:20px; border:1px solid rgba(255,255,255,.3); box-shadow:0 2px 4px rgba(0,0,0,.1) inset; display:flex; align-items:center; gap:4px;">
<span x-text="countdown" style="font-weight:bold; color:#fef08a; font-size:14px;"></span>
</div>
{{-- 跑马阶段 --}}
<div x-show="phase === 'running'" style="display:none;"
style="font-size:12px; color:rgba(255,255,255,.8); background:rgba(0,0,0,.2); padding:2px 10px; border-radius:10px;">
🏇 跑马中…
</div>
{{-- 结算 --}}
<div x-show="phase === 'settled'" style="display:none;"
style="font-size:12px; color:#ffe082; font-weight:bold;">🏆 已结算</div>
<span onclick="Alpine.$data(document.getElementById('horse-race-panel')).close()"
style="cursor:pointer; font-size:18px; color:rgba(255,255,255,.8); line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- 押注进度条(蓝色风格) --}}
<div x-show="phase === 'betting'" style="height:3px; background:#d0e4f5; overflow:hidden;">
<div style="height:100%; background:#336699; transition:width 1s linear;"
:style="'width:' + Math.max(0, (countdown / totalSeconds * 100)) + '%'"></div>
</div>
{{-- ─── 历史趋势(蓝白色系)─── --}}
<div
style="background:#f6faff; padding:6px 16px; border-bottom:1px solid #d0e4f5;
display:flex; gap:5px; align-items:center; flex-wrap:wrap; min-height:32px;">
<span style="color:#336699; font-size:11px; margin-right:2px; font-weight:bold;">近期冒涨:</span>
<template x-for="h in history" :key="h.id">
<span
style="padding:1px 8px; border-radius:10px; font-size:10px; font-weight:bold;
background:#e8f0f8; color:#336699; border:1px solid #b8d0e8;"
:title="'#' + h.id + ' 冠军:' + h.winner_name" x-text="h.winner_name"></span>
</template>
<span x-show="history.length === 0" style="color:#aaa; font-size:11px;">暂无记录</span>
</div>
{{-- ─── 主体内容(白底)─── --}}
<div style="background:#fff; padding:14px 16px;">
{{-- ── 押注阶段 ── --}}
<div x-show="phase === 'betting'">
{{-- 注池统计 --}}
<div
style="color:#336699; font-size:11px; margin-bottom:8px; text-align:center; background:#e8f0f8; border-radius:4px; padding:4px 0;">
注池总额:<span style="color:#b45309; font-weight:bold;"
x-text="Number(totalPool).toLocaleString() + ' 金币'"></span>
</div>
{{-- 马匹列表 --}}
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:12px;">
<template x-for="horse in horses" :key="horse.id">
<div style="border-radius:12px; padding:10px 14px; cursor:pointer; transition:all .15s; border:2px solid transparent;"
:style="selectedHorse === horse.id ?
'background:#e8f0f8; border-color:#336699;' :
'background:#f6faff; border-color:#d0e4f5;'"
@click="myBet ? null : selectedHorse = horse.id">
<div style="display:flex; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; gap:10px;">
{{-- 选中勾选 --}}
<div style="width:20px; height:20px; border-radius:50%; border:2px solid; display:flex; align-items:center; justify-content:center; font-size:10px; flex-shrink:0;"
:style="selectedHorse === horse.id ?
'border-color:#336699; background:#336699; color:#fff' :
'border-color:#b0c8e0; color:transparent'">
</div>
<div style="font-size:22px;" x-text="horse.emoji"></div>
<div>
<div style="color:#225588; font-weight:bold; font-size:13px;"
x-text="horse.name"></div>
<div style="color:#888; font-size:10px;">
注池:<span x-text="Number(horse.pool || 0).toLocaleString()"></span>
</div>
</div>
</div>
{{-- 实时赔率 --}}
<div style="text-align:right;">
<div style="color:#b45309; font-weight:900; font-size:15px;"
x-text="horse.odds ? '×' + horse.odds : '—'"></div>
<div style="color:#999; font-size:10px;">赔率</div>
</div>
</div>
</div>
</template>
</div>
{{-- 已下注状态 --}}
<div x-show="myBet">
<div
style="background:#e8fde8; border:1px solid #a3e6b0; border-radius:6px;
padding:10px 14px; text-align:center; margin-bottom:8px;">
<div style="color:#16a34a; font-weight:bold; font-size:13px;">
已押注「<span x-text="myBetHorseName"></span>
<span x-text="Number(myBetAmount).toLocaleString()"></span> 金币
</div>
<div style="color:#888; font-size:11px; margin-top:3px;">等待开跑…</div>
</div>
</div>
{{-- 下注区 --}}
<div x-show="!myBet">
{{-- 快捷金额 --}}
<div style="display:flex; gap:6px; margin-bottom:8px;">
<template x-for="preset in [100, 500, 1000, 5000, 10000]" :key="preset">
<button @click="betAmount = preset"
style="flex:1; border:1px solid #b0d0ee; border-radius:6px; padding:8px 0;
font-size:13px; font-weight:bold; cursor:pointer; transition:all .15s;"
:style="betAmount === preset ?
'background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; border-color:#2a5580; box-shadow:0 3px 8px rgba(51,102,153,.3);' :
'background:#f6faff; color:#336699;'"
x-text="preset >= 1000 ? (preset/1000)+'k' : preset"></button>
</template>
</div>
<input type="number" x-model.number="betAmount" min="100" placeholder="自定义金额"
style="width:100%; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
padding:10px 14px; color:#333; font-size:13px; box-sizing:border-box; margin-bottom:12px; outline:none; transition:all .15s;"
onfocus="this.style.borderColor='#336699'; this.style.background='#fff'; this.style.boxShadow='0 0 0 2px rgba(51,102,153,.1)';"
onblur="this.style.borderColor='#d0e4f5'; this.style.background='#f6faff'; this.style.boxShadow='none';">
{{-- 下注按钮 --}}
<button @click="submitBet()" :disabled="!selectedHorse || betAmount < 100 || submitting"
style="display:block; width:100%; border:none; border-radius:12px; padding:12px 0;
font-size:14px; font-weight:bold; cursor:pointer; transition:all .2s; box-shadow:0 4px 12px rgba(51,102,153,.2);"
:style="(!selectedHorse || betAmount < 100 || submitting) ?
'background:#e0e8f0; color:#99a8b8; cursor:not-allowed; box-shadow:none;' :
'background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;'">
<span
x-text="submitting ? '提交中…' : (!selectedHorse ? '请先选择马匹' : '🐎 确认押注「' + myBetHorsePreviewName + '」 ' + Number(betAmount).toLocaleString() + ' 金币')"></span>
</button>
</div>
</div>
{{-- ── 跑马阶段 ── --}}
<div x-show="phase === 'running'" style="display:none;">
<div
style="margin-bottom:8px; color:#336699; font-size:11px; font-weight:bold; text-align:center; background:#e8f0f8; border-radius:4px; padding:4px;">
🏁 赛道实况
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<template x-for="horse in horses" :key="horse.id">
<div style="display:flex; align-items:center; gap:8px;">
<div style="width:30px; text-align:center; font-size:18px;" x-text="horse.emoji"></div>
<div style="flex:1; position:relative;">
{{-- 赛道背景 --}}
<div
style="height:24px; background:#e8f0f8; border-radius:10px; overflow:hidden; position:relative;">
{{-- 进度条 --}}
<div style="height:100%; border-radius:20px; transition:width .9s ease-out;"
:style="'width:' + (positions[horse.id] || 0) + '%; background:' +
(leaderId === horse.id ? '#336699' :
'#b8d0e8')">
</div>
{{-- 马匹图标(跟随进度) --}}
<div style="position:absolute; top:50%; transform:translateY(-50%); font-size:16px; transition:left .9s ease-out; pointer-events:none;"
:style="'left:' + Math.max(0, (positions[horse.id] || 0) - 5) + '%'">
<span x-text="horse.emoji"></span>
</div>
</div>
</div>
{{-- 进度数字 --}}
<div style="width:38px; text-align:right; color:#336699; font-size:10px; font-weight:bold;"
x-text="(positions[horse.id] || 0) + '%'"></div>
</div>
</template>
</div>
<div style="margin-top:10px; text-align:center; color:#aaa; font-size:10px; text-align:center;">
🏁 终点线
</div>
</div>
{{-- ── 结算阶段(蓝白风格)── --}}
<div x-show="phase === 'settled'" style="display:none;">
{{-- 获胜马匹 --}}
<div
style="text-align:center; padding:12px 0 10px; border-bottom:1px solid #d0e4f5; margin-bottom:10px;">
<div style="font-size:36px; margin-bottom:4px;" x-text="winnerEmoji"></div>
<div style="color:#336699; font-size:17px; font-weight:bold;"
x-text="'🏆 ' + winnerName + ' 夺冠!'">
</div>
<div style="color:#888; font-size:11px; margin-top:3px;"
x-text="'注池总额:' + Number(totalPool).toLocaleString() + ' 金币'"></div>
</div>
{{-- 个人结果 --}}
<div x-show="myBet">
{{-- 中奖 --}}
<div x-show="myWon"
style="border-radius:6px; padding:12px 14px; text-align:center; margin-bottom:4px;
background:#e8fde8; border:1px solid #a3e6b0;">
<div style="font-size:24px; margin-bottom:4px;">🎉</div>
<div style="color:#16a34a; font-size:16px; font-weight:bold;">恭喜中奖!</div>
<div style="color:#15803d; font-size:18px; font-weight:bold; margin:4px 0;"
x-text="'+' + Number(myPayout).toLocaleString() + ' 💰'"></div>
<div style="color:#888; font-size:10px;"
x-text="'押「' + myBetHorseName + '」' + Number(myBetAmount).toLocaleString() + ' 金币 → 赢得 ' + Number(myPayout).toLocaleString() + ' 金币'">
</div>
</div>
{{-- 未中奖 --}}
<div x-show="!myWon"
style="border-radius:6px; padding:12px 14px; text-align:center;
background:#fff0f0; border:1px solid #fca5a5;">
<div style="font-size:20px; margin-bottom:4px;">😔</div>
<div style="color:#dc2626; font-size:13px; font-weight:bold; margin-bottom:4px;">本场未中奖
</div>
<div style="color:#888; font-size:11px;"
x-text="'押了「' + myBetHorseName + '」' + Number(myBetAmount).toLocaleString() + ' 金币,冠军是「' + winnerName + '」'">
</div>
</div>
</div>
<div x-show="!myBet" style="text-align:center; color:#aaa; font-size:12px; padding:8px 0;">
本场未参与下注
</div>
</div>
</div>
{{-- ─── 底部关闭 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:14px 16px; display:flex; justify-content:center;">
<button @click="close()"
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
</div>
</div>
</div>
<style>
@keyframes horse-run {
0% {
transform: translateX(-2px);
}
100% {
transform: translateX(2px);
}
}
@keyframes pulse-horse {
0%,
100% {
box-shadow: 0 4px 20px rgba(245, 158, 11, .5);
}
50% {
box-shadow: 0 4px 32px rgba(245, 158, 11, .9);
}
}
</style>
<script>
/**
* 赛马竞猜悬浮按钮 Alpine 组件(拖动 + localStorage 位置持久化)
*/
function horseRaceFab() {
const STORAGE_KEY = 'horse_race_fab_pos';
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return {
visible: false,
posX: saved?.x ?? 80,
posY: saved?.y ?? 90,
dragging: false,
_startX: 0,
_startY: 0,
_origX: 0,
_origY: 0,
_moved: false,
startDrag(e) {
this.dragging = true;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
e.currentTarget.setPointerCapture?.(e.pointerId);
},
onDrag(e) {
if (!this.dragging) return;
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
// right 定位:往右拖 dx>0 → right 减小;bottom 定位:往下拖 dy>0 → bottom 减小
this.posX = Math.max(4, Math.min(window.innerWidth - 132, this._origX - dx));
this.posY = Math.max(4, Math.min(window.innerHeight - 132, this._origY - dy));
},
endDrag(e) {
if (!this.dragging) return;
this.dragging = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify({
x: this.posX,
y: this.posY
}));
if (!this._moved) this.openPanel();
},
openPanel() {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openFromHall();
},
};
}
/**
* 赛马竞猜主面板 Alpine 组件
*/
function horseRacePanel() {
return {
show: false,
phase: 'betting', // betting | running | settled
raceId: null,
totalSeconds: 90,
countdown: 90,
countdownTimer: null,
// 马匹列表(含实时赔率)
horses: [],
positions: {}, // 跑马进度 {horse_id: 0~100}
leaderId: null,
// 注池
totalPool: 0,
// 本人下注
myBet: false,
myBetHorseId: null,
myBetHorseName: '',
myBetAmount: 0,
// 下注表单
selectedHorse: null,
betAmount: 100,
submitting: false,
// 结算结果
winnerName: '',
winnerEmoji: '',
myWon: false,
myPayout: 0,
// 历史记录
history: [],
/**
* 获取当前选中马匹的预览名称(用于按钮文字)
*/
get myBetHorsePreviewName() {
if (!this.selectedHorse) return '';
const h = this.horses.find(h => h.id === this.selectedHorse);
return h ? h.emoji + h.name : '';
},
/**
* 开赛:填充场次数据并开始倒计时
*/
openRace(data) {
this.phase = 'betting';
this.raceId = data.race_id;
this.countdown = data.bet_seconds || 90;
this.totalSeconds = this.countdown;
this.horses = data.horses || [];
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = '';
this.myBetAmount = 0;
this.selectedHorse = null;
this.betAmount = 100;
this.positions = {};
this.leaderId = null;
this.show = true;
this.loadCurrentRace();
this.startCountdown();
this.updateFab(true);
},
/**
* 从接口获取当前场次状态(我的下注、注池赔率)
*/
async loadCurrentRace() {
try {
const res = await fetch('/horse-race/current');
const data = await res.json();
if (data.race) {
this.horses = data.race.horses || this.horses;
this.totalPool = data.race.total_pool || 0;
if (data.race.my_bet) {
this.myBet = true;
this.myBetHorseId = data.race.my_bet.horse_id;
this.myBetAmount = data.race.my_bet.amount;
const h = this.horses.find(h => h.id === this.myBetHorseId);
this.myBetHorseName = h ? h.emoji + h.name : '';
}
}
} catch {}
},
/**
* 启动倒计时
*/
startCountdown() {
clearInterval(this.countdownTimer);
this.countdownTimer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
clearInterval(this.countdownTimer);
this.phase = 'running';
}
}, 1000);
},
/**
* 提交下注
*/
async submitBet() {
if (!this.selectedHorse || this.betAmount < 100 || this.submitting) return;
this.submitting = true;
try {
const res = await fetch('/horse-race/bet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
body: JSON.stringify({
race_id: this.raceId,
horse_id: this.selectedHorse,
amount: this.betAmount,
}),
});
const data = await res.json();
if (data.ok) {
this.myBet = true;
this.myBetHorseId = data.horse_id;
this.myBetAmount = data.amount;
const h = this.horses.find(h => h.id === data.horse_id);
this.myBetHorseName = h ? h.emoji + h.name : '';
window.chatDialog?.alert(data.message, '下注成功', '#f59e0b');
} else {
window.chatDialog?.alert(data.message || '下注失败', '提示', '#ef4444');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
}
this.submitting = false;
},
/**
* 接收跑马进度更新
*/
updateProgress(data) {
this.phase = 'running';
this.positions = data.positions || {};
this.leaderId = data.leader_id;
},
/**
* 显示结算结果
*/
showResult(data) {
clearInterval(this.countdownTimer);
this.phase = 'settled';
this.show = true;
// 找出获胜马匹信息
const winner = this.horses.find(h => h.id === data.winner_horse_id);
this.winnerName = winner ? winner.emoji + winner.name : data.winner_name || '未知';
this.winnerEmoji = winner ? winner.emoji : '🐎';
// 判断本人是否中奖
if (this.myBet && this.myBetHorseId === data.winner_horse_id) {
this.myWon = true;
// 赔付前端显示估算(实际以后端为准,后端 WebSocket 无返回赔付金额)
this.myPayout = 0; // 无法前端计算,等用户看下一次余额或后端私信
} else {
this.myWon = false;
this.myPayout = 0;
}
this.updateFab(false);
this.loadHistory();
},
/**
* 加载历史记录
*/
async loadHistory() {
try {
const res = await fetch('/horse-race/history');
const data = await res.json();
this.history = (data.history || []).reverse();
} catch {}
},
/**
* 更新悬浮按钮显示状态
*/
updateFab(visible) {
const fab = document.getElementById('horse-race-fab');
if (fab) Alpine.$data(fab).visible = visible;
},
/**
* 关闭面板
*/
close() {
this.show = false;
if (this.phase === 'betting') {
this.updateFab(true);
}
},
/**
* 从游戏大厅入口打开面板:先重新请求当前场次最新状态,再显示面板。
* 解决游戏大厅展示‚押注中‚但面板状态降旧导致提交报错的问题。
*/
async openFromHall() {
try {
const res = await fetch('/horse-race/current');
const data = await res.json();
if (data.race) {
const race = data.race;
this.raceId = race.id;
this.horses = race.horses || [];
this.totalPool = race.total_pool || 0;
// 更新本人下注状态
if (race.my_bet) {
this.myBet = true;
this.myBetHorseId = race.my_bet.horse_id;
this.myBetAmount = race.my_bet.amount;
const h = this.horses.find(h => h.id === race.my_bet.horse_id);
this.myBetHorseName = h ? h.emoji + h.name : '';
} else {
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = '';
this.myBetAmount = 0;
}
// 同步阶段和倒计时
if (race.status === 'betting' && (race.seconds_left ?? 0) > 0) {
this.phase = 'betting';
this.countdown = race.seconds_left;
this.totalSeconds = race.seconds_left;
this.startCountdown();
} else if (race.status === 'running') {
this.phase = 'running';
} else {
this.phase = 'settled';
}
} else {
// 当前无进行中场次,重置状态
this.raceId = null;
this.horses = [];
this.phase = 'betting';
this.countdown = 0;
}
} catch (e) {
console.warn('[\u8d5b\u9a6c] openFromHall 失\u8d25', e);
}
this.show = true;
},
};
}
// ─── WebSocket 监听 ──────────────────────────────────────────────
/** 收到开赛事件:弹出押注面板 */
window.addEventListener('chat:horse.opened', (e) => {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openRace(e.detail);
});
/** 收到跑马进度事件:更新赛道 */
window.addEventListener('chat:horse.progress', (e) => {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).updateProgress(e.detail);
});
/** 收到结算事件:展示结果 */
window.addEventListener('chat:horse.settled', (e) => {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).showResult(e.detail);
});
/** 页面加载时恢复进行中的场次 */
document.addEventListener('DOMContentLoaded', async () => {
try {
const histRes = await fetch('/horse-race/history');
const histData = await histRes.json();
const panel = document.getElementById('horse-race-panel');
const fab = document.getElementById('horse-race-fab');
if (panel) {
Alpine.$data(panel).history = (histData.history || []).reverse();
}
const curRes = await fetch('/horse-race/current');
const curData = await curRes.json();
// 游戏可访问则常驻显示 FAB(与占卜一致)
if (fab) Alpine.$data(fab).visible = true;
if (curData.race && panel) {
const race = curData.race;
const seconds = race.seconds_left || 0;
const panelData = Alpine.$data(panel);
panelData.raceId = race.id;
panelData.horses = race.horses || [];
panelData.totalPool = race.total_pool || 0;
if (race.my_bet) {
panelData.myBet = true;
panelData.myBetHorseId = race.my_bet.horse_id;
panelData.myBetAmount = race.my_bet.amount;
const h = panelData.horses.find(h => h.id === race.my_bet.horse_id);
panelData.myBetHorseName = h ? h.emoji + h.name : '';
}
if (race.status === 'betting' && seconds > 0) {
panelData.phase = 'betting';
panelData.countdown = seconds;
} else if (race.status === 'running') {
panelData.phase = 'running';
}
}
} catch (e) {
console.warn('[赛马] 初始化失败', e);
}
});
</script>
@@ -0,0 +1,644 @@
{{--
文件功能:双色球彩票前台弹窗组件
选号购票面板:
- 红球 1~12 选3 + 蓝球 1~6 选1,可机选
- 支持多注购买(单次最多10注)和追加购买
- 展示当期奖池、倒计时、本期已购记录
- 展示最近10期历史开奖号码
- 规则折叠说明卡片
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ─── 彩票面板遮罩 ─── --}}
<div id="lottery-panel" x-data="lotteryPanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-250" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-180"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9935;
display:flex; align-items:center; justify-content:center;">
<div
style="width:480px; max-width:96vw; max-height:92vh; border-radius:10px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.35); font-family:'Microsoft YaHei',SimSun,sans-serif;
background:#fff; display:flex; flex-direction:column;">
{{-- ─── 标题栏 ─── --}}
<div
style="background:linear-gradient(135deg,#dc2626,#ef4444); padding:10px 16px;
display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
<div>
<div style="color:#fff; font-weight:bold; font-size:14px;">🎟️ 双色球彩票</div>
<div style="color:rgba(255,255,255,.8); font-size:11px; margin-top:1px;">
<span x-text="issueNo">--</span> &nbsp;|&nbsp;
<span x-text="isOpen ? '🟢 购票中' : (status === 'settled' ? '✅ 已开奖' : '🔴 已停售')"></span>
</div>
</div>
<div style="text-align:right;">
{{-- 奖池金额 --}}
<div style="color:#fef08a; font-size:18px; font-weight:900; line-height:1;"
x-text="'💰 ' + poolAmount.toLocaleString()"></div>
{{-- 超级期标记 --}}
<div x-show="isSuperIssue" style="color:#fcd34d; font-size:10px; font-weight:bold; margin-top:2px;">
🎊
超级期</div>
{{-- 距开奖倒计时 --}}
<div x-show="!isSuperIssue && isOpen"
style="color:rgba(255,255,255,.8); font-size:10px; margin-top:2px;"
x-text="'距开奖 ' + countdownText">
</div>
</div>
<span onclick="closeLotteryPanel()"
style="cursor:pointer; font-size:20px; color:#fff; opacity:.8; line-height:1; margin-left:12px;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 内容区(可滚动)─── --}}
<div style="flex:1; overflow-y:auto; background:#fafafa;">
{{-- 加载中 --}}
<div x-show="loading" style="text-align:center; padding:40px 0; color:#dc2626; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;"></div>加载中…
</div>
<div x-show="!loading">
{{-- ─── 规则说明折叠区 ─── --}}
<div style="border-bottom:1px solid #fee2e2; background:#fff7f7;">
<div @click="ruleOpen=!ruleOpen"
style="padding:8px 14px; display:flex; align-items:center; justify-content:space-between;
cursor:pointer; font-size:12px; color:#b91c1c; font-weight:bold; user-select:none;">
<span>📖 玩法规则</span>
<span x-text="ruleOpen ? '▲ 收起' : '▼ 展开'" style="font-size:10px; opacity:.7;"></span>
</div>
<div x-show="ruleOpen"
style="padding:0 14px 10px; font-size:11px; color:#6b7280; line-height:1.8;">
<div>🔴 <b>红球</b> 1~12 选3 · 🎲 <b>蓝球</b> 1~6 选1,每注 <b style="color:#dc2626;">100 金币</b>
</div>
<div>每期单人最多购 <b>50 </b>,可随时追加(停售前均可)</div>
<table style="width:100%; margin-top:6px; border-collapse:collapse;">
<tr style="background:#fee2e2;">
<th style="padding:3px 6px; text-align:left; font-size:10px;">奖级</th>
<th style="padding:3px 6px; text-align:left; font-size:10px;">条件</th>
<th style="padding:3px 6px; text-align:left; font-size:10px;">奖励</th>
</tr>
<tr>
<td style="padding:2px 6px;">🏆 一等奖</td>
<td>3+</td>
<td style="color:#dc2626;">奖池 60% 均分</td>
</tr>
<tr style="background:#fff7f7;">
<td style="padding:2px 6px;">🥇 二等奖</td>
<td>3</td>
<td style="color:#dc2626;">奖池 20% 均分</td>
</tr>
<tr>
<td style="padding:2px 6px;">🥈 三等奖</td>
<td>2+</td>
<td>奖池 10% 均分</td>
</tr>
<tr style="background:#fff7f7;">
<td style="padding:2px 6px;">🥉 四等奖</td>
<td>2</td>
<td>固定 150 /</td>
</tr>
<tr>
<td style="padding:2px 6px;">🎫 五等奖</td>
<td>1+</td>
<td>固定 50 /</td>
</tr>
</table>
<div style="margin-top:6px; color:#9ca3af;">无一等奖 奖池滚存至下期;连续3期无人中 超级期系统注入奖池</div>
</div>
</div>
{{-- 已开奖结果展示 --}}
<div x-show="status === 'settled' && drawRed1"
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, $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="n !== null ? String(n).padStart(2,'0') : '--'"></span>
</template>
<span style="color:#6b7280; font-size:18px; font-weight:bold;">+</span>
<span
style="width:32px; height:32px; border-radius:50%; background:#2563eb;
color:#fff; font-weight:bold; font-size:14px;
display:flex; align-items:center; justify-content:center;"
x-text="drawBlue"></span>
</div>
</div>
{{-- ─── 选号区 ─── --}}
<div x-show="isOpen" style="padding:12px 14px;">
{{-- 红球选择 --}}
<div style="margin-bottom:10px;">
<div style="font-size:12px; font-weight:bold; color:#b91c1c; margin-bottom:6px;">
🔴 红球(已选 <span x-text="selectedReds.length" style="color:#dc2626;"></span>/3
</div>
<div style="display:grid; grid-template-columns:repeat(6,1fr); gap:5px;">
<template x-for="n in 12" :key="n">
<button @click="toggleRed(n)"
:style="selectedReds.includes(n) ?
'background:#dc2626; color:#fff; border:2px solid #b91c1c; font-weight:bold;' :
'background:#fff; color:#dc2626; border:2px solid #fca5a5;'"
style="border-radius:50%; width:38px; height:38px; font-size:13px;
cursor:pointer; transition:all .15s; display:flex; align-items:center; justify-content:center;"
x-text="String(n).padStart(2,'0')">
</button>
</template>
</div>
</div>
{{-- 蓝球选择 --}}
<div style="margin-bottom:12px;">
<div style="font-size:12px; font-weight:bold; color:#1d4ed8; margin-bottom:6px;">
🎲 蓝球(骰子点数,已选 <span x-text="selectedBlue ?? '—'" style="color:#2563eb;"></span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<template x-for="n in 6" :key="n">
<button @click="selectedBlue = (selectedBlue === n ? null : n)"
:style="selectedBlue === n ?
'background:#2563eb; color:#fff; border:2px solid #1d4ed8; font-weight:bold;' :
'background:#fff; color:#2563eb; border:2px solid #93c5fd;'"
style="border-radius:50%; width:38px; height:38px; font-size:16px;
cursor:pointer; transition:all .15s;"
x-text="['🎲','⚀','⚁','⚂','⚃','⚄','⚅'][n]">
</button>
</template>
</div>
</div>
{{-- 当前选号预览 --}}
<div x-show="selectedReds.length > 0 || selectedBlue"
style="background:#fff7f7; border:1px dashed #fca5a5; border-radius:8px;
padding:8px 12px; margin-bottom:10px; font-size:12px; color:#6b7280;">
已选:
<template x-for="n in [...selectedReds].sort((a,b)=>a-b)" :key="n">
<span
style="display:inline-block; width:26px; height:26px; border-radius:50%; background:#dc2626;
color:#fff; font-size:11px; font-weight:bold; text-align:center; line-height:26px; margin:0 2px;"
x-text="String(n).padStart(2,'0')"></span>
</template>
<span x-show="selectedBlue"
style="display:inline-block; width:26px; height:26px; border-radius:50%; background:#2563eb;
color:#fff; font-size:11px; font-weight:bold; text-align:center; line-height:26px; margin-left:6px;"
x-text="selectedBlue">
</span>
</div>
{{-- 操作按鈕行(机选 + 清除 + 加入购物车) --}}
<div style="display:flex; gap:8px; margin-bottom:12px;">
<button @click="doQuickPick(1)"
style="flex:1; padding:10px 0; border-radius:10px; font-size:13px; font-weight:bold;
background:#f0f6ff; color:#336699; border:1.5px solid #c0d8ef;
cursor:pointer; transition:all .15s; font-family:inherit;"
onmouseover="this.style.background='#dbeafe'; this.style.borderColor='#93c5fd'"
onmouseout="this.style.background='#f0f6ff'; this.style.borderColor='#c0d8ef'">
机选
</button>
<button @click="selectedReds=[]; selectedBlue=null;"
style="flex:1; padding:10px 0; border-radius:10px; font-size:13px; font-weight:bold;
background:#f9fafb; color:#6b7280; border:1.5px solid #e5e7eb;
cursor:pointer; transition:all .15s; font-family:inherit;"
onmouseover="this.style.background='#f3f4f6'"
onmouseout="this.style.background='#f9fafb'">
🗑 清除
</button>
<button @click="addToCart()" :disabled="selectedReds.length !== 3 || !selectedBlue"
style="flex:1.3; padding:10px 0; border-radius:10px; font-size:13px; font-weight:bold;
cursor:pointer; transition:all .15s; font-family:inherit; border:none;"
:style="selectedReds.length === 3 && selectedBlue ?
'background:linear-gradient(135deg,#d97706,#f59e0b); color:#fff; box-shadow:0 3px 10px rgba(217,119,6,.3);' :
'background:#f3f4f6; color:#9ca3af; cursor:not-allowed;'">
&#xFF0B; 加入购物车
</button>
</div>
{{-- 购物车(待购清单) --}}
<div x-show="cart.length > 0"
style="background:#fff7f7; border:1px solid #fecaca; border-radius:8px; padding:10px; margin-bottom:10px;">
<div style="font-size:12px; font-weight:bold; color:#b91c1c; margin-bottom:6px;">
📋 待购清单(<span x-text="cart.length"></span> 注,
💰 <span x-text="cart.length * 100"></span> 金币)
</div>
<div style="max-height:100px; overflow-y:auto;">
<template x-for="(item, idx) in cart" :key="idx">
<div
style="display:flex; align-items:center; justify-content:space-between;
font-size:11px; color:#6b7280; padding:2px 0; border-bottom:1px dashed #fee2e2;">
<span>
<span x-text="idx+1"></span>
<template x-for="r in item.reds" :key="r">
<span
style="display:inline-block; width:20px; height:20px; border-radius:50%; background:#dc2626;
color:#fff; font-size:10px; text-align:center; line-height:20px; margin:0 1px;"
x-text="String(r).padStart(2,'0')"></span>
</template>
<span
style="display:inline-block; width:20px; height:20px; border-radius:50%; background:#2563eb;
color:#fff; font-size:10px; text-align:center; line-height:20px; margin-left:4px;"
x-text="item.blue"></span>
</span>
<button @click="cart.splice(idx,1)"
style="color:#dc2626; background:none; border:none; cursor:pointer; font-size:12px;"></button>
</div>
</template>
</div>
<button @click="submitCart()" :disabled="buying"
style="width:100%; margin-top:10px; border:none; border-radius:12px;
padding:13px 0; font-size:14px; font-weight:bold;
cursor:pointer; transition:all .2s; font-family:inherit;"
:style="buying
?
'background:#e0e8f0; color:#99a8b8; cursor:not-allowed; box-shadow:none;' :
'background:linear-gradient(135deg,#dc2626,#ef4444); color:#fff; box-shadow:0 4px 14px rgba(220,38,38,.3);'">
<span
x-text="buying ? '购买中…' : '🎟️ 确认购买 ' + cart.length + ' 注(共 ' + (cart.length*100).toLocaleString() + ' 金币)'"></span>
</button>
{{-- 内联购买结果提示 --}}
<div id="lottery-buy-msg"
style="display:none; border-radius:8px; padding:8px 12px; text-align:center;
margin-top:8px; font-size:12px; font-weight:bold;">
</div>
</div>
</div>
{{-- 停售/已开奖时的提示 --}}
<div x-show="!isOpen && status !== 'settled'"
style="padding:16px 14px; text-align:center; color:#b91c1c; font-size:13px;">
🔴 本期已停售,等待开奖中…
</div>
{{-- ─── 本期我的购票记录 ─── --}}
<div x-show="myTickets.length > 0" style="padding:10px 14px; border-top:1px solid #fee2e2;">
<div style="font-size:12px; font-weight:bold; color:#b91c1c; margin-bottom:6px;">
📋 本期我的购票(<span x-text="myTickets.length"></span> 注)
</div>
<div style="max-height:120px; overflow-y:auto;">
<template x-for="(t, idx) in myTickets" :key="t.id">
<div
style="display:flex; align-items:center; justify-content:space-between;
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, $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;"
x-text="String(r).padStart(2,'0')"></span>
</template>
<span
style="width:20px; height:20px; border-radius:50%; background:#2563eb;
color:#fff; font-size:11px; text-align:center; line-height:20px; margin-left:2px;"
x-text="t.blue"></span>
<span x-show="t.is_quick" style="color:#9ca3af; font-size:10px;">机选</span>
</div>
{{-- 中奖标记 --}}
<span x-show="t.prize_level > 0"
style="color:#dc2626; font-weight:bold; font-size:11px;"
x-text="['','🏆一等','🥇二等','🥈三等','🥉四等','🎫五等'][t.prize_level] + ' +' + t.payout.toLocaleString()"></span>
<span x-show="t.prize_level === 0 && status === 'settled'"
style="color:#9ca3af; font-size:11px;">未中奖</span>
</div>
</template>
</div>
</div>
{{-- ─── 历史开奖记录 ─── --}}
<div style="padding:10px 14px; border-top:1px solid #fee2e2;">
<div style="font-size:12px; font-weight:bold; color:#b91c1c; margin-bottom:6px;">📜 近期开奖</div>
<div x-show="history.length === 0"
style="font-size:11px; color:#9ca3af; text-align:center; padding:8px 0;">
暂无历史记录
</div>
<div style="overflow-x:auto;">
<table style="width:100%; font-size:10px; border-collapse:collapse; min-width:300px;">
<tr style="background:#fee2e2; color:#b91c1c;">
<th style="padding:3px 5px; text-align:left;">期号</th>
<th style="padding:3px 5px;">开奖号码</th>
<th style="padding:3px 5px; text-align:right;">奖池</th>
</tr>
<template x-for="(h, i) in history.slice(0,8)" :key="h.issue_no">
<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, $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;
text-align:center; line-height:18px; margin:0 1px;"
x-text="String(r).padStart(2,'0')"></span>
</template>
<span
style="display:inline-block; width:18px; height:18px; border-radius:50%;
background:#2563eb; color:#fff; font-size:9px; font-weight:bold;
text-align:center; line-height:18px; margin-left:3px;"
x-text="h.blue"></span>
</td>
<td style="padding:3px 5px; text-align:right; color:#dc2626;"
x-text="'💰' + Number(h.pool_amount).toLocaleString()"></td>
</tr>
</template>
</table>
</div>
</div>
</div>{{-- /!loading --}}
</div>{{-- /内容区 --}}
{{-- ─── 底部操作栏 ─── --}}
<div
style="padding:10px 14px; background:#fff; border-top:1px solid #fee2e2;
display:flex; justify-content:space-between; align-items:center; flex-shrink:0;">
<button @click="doQuickPick(3)" x-show="isOpen"
style="padding:9px 18px; border-radius:20px; font-size:12px; font-weight:bold;
background:linear-gradient(135deg,#d97706,#f59e0b); color:#fff; border:none;
box-shadow:0 3px 10px rgba(217,119,6,.25); cursor:pointer;
transition:all .15s; font-family:inherit;"
onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 5px 14px rgba(217,119,6,.35)'"
onmouseout="this.style.transform=''; this.style.boxShadow='0 3px 10px rgba(217,119,6,.25)'">
机选 3
</button>
<button @click="loadData()"
style="padding:9px 18px; border-radius:20px; font-size:12px; font-weight:bold;
background:#f0f6ff; color:#336699; border:1.5px solid #c0d8ef;
cursor:pointer; transition:all .15s; font-family:inherit;"
onmouseover="this.style.background='#dbeafe'" onmouseout="this.style.background='#f0f6ff'">
🔄 刷新
</button>
<button @click="closeLotteryPanel()"
style="padding:9px 18px; border-radius:20px; font-size:12px; font-weight:bold;
background:#f9fafb; color:#6b7280; border:1.5px solid #e5e7eb;
cursor:pointer; transition:all .15s; font-family:inherit;"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#f9fafb'">
关闭
</button>
</div>
</div>
</div>
</div>
<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,
// ─── 选号 ───
selectedReds: [],
selectedBlue: null,
cart: [], // 待购清单
// ─── 数据 ───
myTickets: [],
history: [],
// ─── 倒计时 ───
_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')}`;
},
/**
* 打开面板并加载数据
*/
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
});
}
}
// 清空当前选号
this.selectedReds = [];
this.selectedBlue = null;
} catch {}
},
/**
* 将当前选号加入购物车
*/
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;
},
/**
* 提交购物车(批量购买)
*/
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 = [];
// 更新金币余额显示
if (window.chatContext) {
window.chatContext.userJjb = Math.max(0, (window.chatContext.userJjb ?? 0) - data
.count * 100);
}
// 刷新我的购票列表
await this.loadData();
} else {
showLotteryMsg('❌ ' + (data.message || '购票失败'), false);
}
} catch {
showLotteryMsg('🌐 网络异常,请稍后重试', 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();
};
/** 关闭彩票面板 */
window.closeLotteryPanel = function() {
const panel = document.getElementById('lottery-panel');
if (panel) {
const data = Alpine.$data(panel);
data.show = false;
clearInterval(data._timer);
}
};
</script>
@@ -0,0 +1,422 @@
{{--
文件功能:神秘箱子游戏前台UI组件
功能描述:
- 右下角悬浮提示标(检测到可领取箱子时显示,支持拖动移位)
- 监听聊天消息事件,识别公屏暗号提示后自动出现
- 用户在聊天框输入暗号(由前端拦截 /mystery-box/claim 接口)
- 或点击悬浮图标展开快速输入界面
- 开箱结果展示 toast 通知
--}}
{{-- ─── 神秘箱子悬浮提示(可拖动) ─── --}}
<div id="mystery-box-fab" x-data="mysteryBoxFab()" x-show="visible" x-cloak
:style="'position:fixed; left:' + posX + 'px; top:' + posY + 'px; z-index:9880; touch-action:none; user-select:none;'"
@pointerdown.prevent="startDrag($event)" @pointermove.window="onDrag($event)" @pointerup.window="endDrag($event)"
@pointercancel.window="endDrag($event)">
{{-- 悬浮圆形按钮 --}}
<button
style="width:56px; height:56px; border-radius:50%; border:none; position:relative;
font-size:24px; display:flex; align-items:center; justify-content:center;
animation:mb-pulse 1.8s ease-in-out infinite; user-select:none;"
:style="[
dragging ? 'cursor:grabbing;' : 'cursor:grab;',
boxType === 'rare' ?
'background:linear-gradient(135deg,#7c3aed,#a78bfa); box-shadow:0 4px 24px rgba(124,58,237,.6);' :
boxType === 'trap' ?
'background:linear-gradient(135deg,#7f1d1d,#dc2626); box-shadow:0 4px 24px rgba(220,38,38,.6);' :
'background:linear-gradient(135deg,#065f46,#10b981); box-shadow:0 4px 24px rgba(16,185,129,.6);'
]"
title="神秘箱子开箱中!(可拖动)">
<span x-text="boxEmoji">📦</span>
{{-- 倒计时badge --}}
<span x-show="secondsLeft !== null && secondsLeft <= 30"
style="position:absolute; top:-6px; right:-6px; background:#ef4444; color:#fff;
font-size:10px; font-weight:bold; border-radius:10px; padding:1px 5px; min-width:18px; text-align:center;"
x-text="secondsLeft">
</span>
</button>
{{-- 悬浮提示标签 --}}
<div style="position:absolute; left:62px; top:50%; transform:translateY(-50%);
background:rgba(0,0,0,.85); color:#fff; border-radius:8px; padding:4px 10px;
font-size:12px; white-space:nowrap; pointer-events:none;"
:style="boxType === 'rare' ? 'border:1px solid rgba(167,139,250,.4);' :
boxType === 'trap' ? 'border:1px solid rgba(239,68,68,.3);' :
'border:1px solid rgba(16,185,129,.3);'">
<span x-text="boxTypeName">神秘箱</span>
<span x-show="secondsLeft !== null" style="margin-left:4px; opacity:.7;" x-text="'⏰ ' + secondsLeft + 's'">
</span>
</div>
</div>
{{-- ─── 神秘箱子快捷输入面板 ─── --}}
<div id="mystery-box-panel" x-data="mysteryBoxPanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.75); z-index:9920;
display:flex; align-items:center; justify-content:center;">
<div style="width:380px; max-width:94vw; border-radius:24px; overflow:hidden;
font-family:system-ui,sans-serif; position:relative;"
:style="boxType === 'rare' ?
'box-shadow:0 24px 80px rgba(124,58,237,.5); background:linear-gradient(180deg,#1e1b4b,#2e1065);' :
boxType === 'trap' ?
'box-shadow:0 24px 80px rgba(220,38,38,.4); background:linear-gradient(180deg,#1c0606,#2d0909);' :
'box-shadow:0 24px 80px rgba(16,185,129,.4); background:linear-gradient(180deg,#022c22,#064e3b);'">
{{-- ─── 顶部 ─── --}}
<div style="padding:20px 22px 16px;"
:style="boxType === 'rare' ?
'background:linear-gradient(135deg,#4c1d95,#6d28d9,#7c3aed);' :
boxType === 'trap' ?
'background:linear-gradient(135deg,#7f1d1d,#b91c1c,#dc2626);' :
'background:linear-gradient(135deg,#064e3b,#059669,#10b981);'">
<div style="display:flex; align-items:center; gap:12px;">
<div style="font-size:36px;" x-text="boxEmoji">📦</div>
<div>
<div style="color:#fff; font-weight:900; font-size:17px;" x-text="boxTypeName + ' 开箱!'"></div>
<div style="color:rgba(255,255,255,.6); font-size:12px; margin-top:2px;">
输入暗号即可开箱,限时
<span x-text="secondsLeft !== null ? secondsLeft + ' 秒' : ''"
style="color:#fbbf24; font-weight:bold;"></span>
</div>
</div>
</div>
{{-- 倒计时进度条 --}}
<div x-show="secondsLeft !== null && totalSeconds > 0"
style="margin-top:10px; height:3px; background:rgba(255,255,255,.15); border-radius:2px; overflow:hidden;">
<div style="height:100%; border-radius:2px; transition:width 1s linear;"
:style="'width:' + Math.max(0, secondsLeft / totalSeconds * 100) + '%; background:' +
(boxType === 'rare' ? '#c4b5fd' : boxType === 'trap' ? '#fca5a5' : '#6ee7b7')">
</div>
</div>
</div>
{{-- ─── 输入区 ─── --}}
<div style="padding:20px;">
{{-- 奖励提示 --}}
<div style="text-align:center; margin-bottom:14px; padding:10px; border-radius:12px;"
:style="boxType === 'rare' ?
'background:rgba(124,58,237,.15); border:1px solid rgba(167,139,250,.25);' :
boxType === 'trap' ?
'background:rgba(220,38,38,.1); border:1px solid rgba(248,113,113,.2);' :
'background:rgba(16,185,129,.1); border:1px solid rgba(52,211,153,.2);'">
<div style="font-size:13px; opacity:.8;"
x-text="
boxType === 'rare' ? '✨ 稀有箱 · 奖励丰厚,手速要快!' :
boxType === 'trap' ? '⚠️ 黑化箱 · 开了可能倒扣金币!谨慎开启!' :
'🎁 发送正确暗号即可开箱领奖'
"
style="color:rgba(255,255,255,.75);"></div>
</div>
{{-- 暗号输入框 --}}
<div style="margin-bottom:12px;">
<input type="text" x-model="inputCode" placeholder="请输入暗号(区分大小写)"
@keydown.enter="doClaimFromPanel()" @input="inputCode = $event.target.value.toUpperCase()"
style="width:100%; border-radius:10px; padding:12px 14px; font-size:15px;
font-weight:bold; text-align:center; letter-spacing:4px; text-transform:uppercase;
background:rgba(255,255,255,.1); border:2px solid rgba(255,255,255,.2);
color:#fff; box-sizing:border-box; outline:none; transition:border-color .15s;"
:style="boxType === 'rare' ? '&:focus { border-color:#a78bfa; }' : ''">
</div>
{{-- 提交按钮 --}}
<button @click="doClaimFromPanel()" :disabled="!inputCode.trim() || claiming"
style="width:100%; border:none; border-radius:12px; padding:13px; font-size:15px;
font-weight:900; cursor:pointer; transition:all .2s; letter-spacing:2px;"
:style="(!inputCode.trim() || claiming) ? {
background: 'rgba(255,255,255,.08)',
color: 'rgba(255,255,255,.3)',
cursor: 'not-allowed'
} : boxType === 'rare' ? {
background: 'linear-gradient(135deg,#7c3aed,#8b5cf6,#a78bfa)',
color: '#fff',
boxShadow: '0 4px 20px rgba(124,58,237,.5)'
} : boxType === 'trap' ? {
background: 'linear-gradient(135deg,#b91c1c,#dc2626,#ef4444)',
color: '#fff',
boxShadow: '0 4px 20px rgba(220,38,38,.5)'
} : {
background: 'linear-gradient(135deg,#059669,#10b981,#34d399)',
color: '#fff',
boxShadow: '0 4px 20px rgba(16,185,129,.5)'
}">
<span x-text="claiming ? '🎁 开箱中…' : '🎁 开箱!'"></span>
</button>
</div>
{{-- ─── 底部关闭 ─── --}}
<div style="padding:8px 20px 12px; display:flex; justify-content:center;">
<button @click="show = false"
style="padding:6px 24px; background:rgba(255,255,255,.06); border:none; border-radius:20px;
font-size:12px; color:rgba(255,255,255,.4); cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='rgba(255,255,255,.12)'"
onmouseout="this.style.background='rgba(255,255,255,.06)'">
关闭
</button>
</div>
</div>
</div>
</div>
<style>
@keyframes mb-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@keyframes mb-shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
}
</style>
<script>
/**
* 神秘箱子悬浮按钮 Alpine 组件
* 支持拖动 + 位置持久化 + 倒计时显示
*/
function mysteryBoxFab() {
const STORAGE_KEY = 'mystery_box_fab_pos';
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return {
visible: false,
posX: saved?.x ?? 18,
posY: saved?.y ?? 240,
dragging: false,
_startX: 0,
_startY: 0,
_origX: 0,
_origY: 0,
_moved: false,
boxType: 'normal',
boxTypeName: '普通神秘箱',
boxEmoji: '📦',
secondsLeft: null,
totalSeconds: null,
_timer: null,
/**
* 初始化:轮询接口检测箱子
*/
init() {
this.checkStatus();
// 每5秒轮询一次
setInterval(() => this.checkStatus(), 5000);
},
/**
* 检查当前是否有可领取的箱子
*/
async checkStatus() {
try {
const res = await fetch('/mystery-box/status');
const data = await res.json();
if (data.active) {
this.visible = true;
this.boxType = data.box_type;
this.boxTypeName = data.type_name;
this.boxEmoji = data.type_emoji;
this.secondsLeft = data.seconds_left;
if (this.totalSeconds === null || data.seconds_left > this.totalSeconds) {
this.totalSeconds = data.seconds_left;
}
this.startCountdown();
// ── 全局标志:供聊天框暗号拦截使用 ──
window._mysteryBoxActive = true;
window._mysteryBoxPasscode = data.passcode ?? null;
// 同步面板数据
const panel = document.getElementById('mystery-box-panel');
if (panel) {
const pd = Alpine.$data(panel);
pd.boxType = data.box_type;
pd.boxTypeName = data.type_name;
pd.boxEmoji = data.type_emoji;
pd.secondsLeft = data.seconds_left;
pd.totalSeconds = this.totalSeconds;
}
} else {
this.visible = false;
clearInterval(this._timer);
// ── 清除全局标志 ──
window._mysteryBoxActive = false;
window._mysteryBoxPasscode = null;
// 关闭面板
const panel = document.getElementById('mystery-box-panel');
if (panel) Alpine.$data(panel).show = false;
}
} catch {}
},
/**
* 启动倒计时(本地)
*/
startCountdown() {
clearInterval(this._timer);
this._timer = setInterval(() => {
if (this.secondsLeft !== null && this.secondsLeft > 0) {
this.secondsLeft--;
// 同步面板
const panel = document.getElementById('mystery-box-panel');
if (panel) Alpine.$data(panel).secondsLeft = this.secondsLeft;
} else {
clearInterval(this._timer);
this.visible = false;
}
}, 1000);
},
/**
* 拖动开始
*/
startDrag(e) {
this.dragging = true;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
e.currentTarget.setPointerCapture?.(e.pointerId);
},
/**
* 拖动移动
*/
onDrag(e) {
if (!this.dragging) return;
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
this.posX = Math.max(4, Math.min(window.innerWidth - 80, this._origX + dx));
this.posY = Math.max(4, Math.min(window.innerHeight - 70, this._origY + dy));
},
/**
* 拖动结束,非拖动时打开面板
*/
endDrag(e) {
if (!this.dragging) return;
this.dragging = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify({
x: this.posX,
y: this.posY
}));
if (!this._moved) {
// 打开快捷输入面板
const panel = document.getElementById('mystery-box-panel');
if (panel) Alpine.$data(panel).show = true;
}
},
};
}
/**
* 神秘箱子快捷输入面板 Alpine 组件
*/
function mysteryBoxPanel() {
return {
show: false,
boxType: 'normal',
boxTypeName: '神秘箱',
boxEmoji: '📦',
secondsLeft: null,
totalSeconds: null,
inputCode: '',
claiming: false,
/**
* 从面板提交开箱暗号
*/
async doClaimFromPanel() {
if (!this.inputCode.trim() || this.claiming) return;
this.claiming = true;
const result = await window.mysteryBoxClaim(this.inputCode.trim());
this.claiming = false;
if (result.ok) {
this.show = false;
this.inputCode = '';
}
},
};
}
/**
* 全局开箱函数(可被聊天框暗号处理逻辑调用)
*
* @param {string} passcode 用户输入的暗号
* @returns {Promise<{ok: boolean, message?: string, reward?: number}>}
*/
window.mysteryBoxClaim = async function(passcode) {
try {
const res = await fetch('/mystery-box/claim', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
body: JSON.stringify({
passcode
}),
});
const data = await res.json();
if (data.ok) {
// 成功动画提示
const isPositive = data.reward > 0;
window.chatDialog?.alert(
data.message || '开箱成功!',
isPositive ? '🎉 恭喜!' : '☠️ 中了陷阱!',
isPositive ? '#10b981' : '#ef4444',
);
// 更新全局金币余额
if (window.__chatUser) window.__chatUser.jjb = data.balance;
} else {
window.chatDialog?.alert(data.message || '开箱失败', '提示', '#f59e0b');
}
return data;
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
return {
ok: false
};
}
};
</script>
@@ -0,0 +1,688 @@
{{--
文件功能:礼包红包弹窗面板(HTML + CSS + 交互脚本)
包含:
1. 红包遮罩弹窗 DOM#red-packet-modal
2. 红包弹窗样式(CSS
3. 红包前端交互 JS(发包、抢包、倒计时、WebSocket 监听)
全局函数:
window.sendRedPacket() superlevel 发包(弹 chatBanner 选类型)
window.showRedPacketModal(...) 展示红包弹窗(收到 WebSocket 事件触发)
window.closeRedPacketModal() 关闭红包弹窗
window.claimRedPacket() 用户抢红包
window.updateRedPacketClaimsUI() 更新领取名单(WebSocket 广播后调用)
注:依赖 window.chatBannerchat-banner.blade.php)、window.chatDialog、window.chatToast。
scripts.blade.php 提取,与其他游戏面板(baccarat-panel、slot-machine 等)保持统一结构。
@author ChatRoom Laravel
@version 1.0.0
--}}
<style>
/* 红包弹窗遮罩 */
#red-packet-modal {
display: none;
position: fixed !important;
inset: 0 !important;
z-index: 10500 !important;
background: rgba(0, 0, 0, 0.6) !important;
justify-content: center !important;
align-items: center !important;
animation: rpFadeIn 0.25s ease;
}
@keyframes rpFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 红包卡片主体 */
#red-packet-card {
width: 300px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(220, 38, 38, 0.4);
animation: rpCardIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
}
@keyframes rpCardIn {
from {
transform: scale(0.7) translateY(40px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
/* 红包顶部区 */
#rp-header {
background: linear-gradient(160deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%);
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
#rp-header::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(255, 100, 0, 0.3) 0%, transparent 70%);
pointer-events: none;
}
.rp-emoji {
font-size: 52px;
display: block;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
animation: rpBounce 1.5s ease-in-out infinite;
}
@keyframes rpBounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.rp-sender {
color: #fde68a;
font-size: 13px;
margin-top: 8px;
font-weight: bold;
}
.rp-title {
color: #fff;
font-size: 18px;
font-weight: bold;
margin-top: 4px;
letter-spacing: 1px;
}
.rp-amount {
color: #fde68a;
font-size: 28px;
font-weight: bold;
margin-top: 6px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.rp-amount small {
font-size: 14px;
opacity: 0.85;
}
/* 红包底部区 */
#rp-body {
background: #fff8f0;
padding: 16px 20px;
}
.rp-info-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #92400e;
margin-bottom: 8px;
}
/* 倒计时条 */
#rp-timer-bar-wrap {
background: #fee2e2;
border-radius: 4px;
height: 6px;
overflow: hidden;
margin-bottom: 14px;
}
#rp-timer-bar {
height: 100%;
background: linear-gradient(90deg, #dc2626, #f97316);
border-radius: 4px;
transition: width 1s linear;
}
/* 领取按钮 */
#rp-claim-btn {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #dc2626, #ea580c);
color: #fff;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 10px;
cursor: pointer;
letter-spacing: 1px;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
transition: opacity .2s, transform .15s;
}
#rp-claim-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
#rp-claim-btn:active {
transform: translateY(0);
}
#rp-claim-btn:disabled {
background: #9ca3af;
box-shadow: none;
cursor: not-allowed;
transform: none;
}
/* 已领取名单 */
#rp-claims-list {
margin-top: 12px;
max-height: 100px;
overflow-y: auto;
border-top: 1px dashed #fca5a5;
padding-top: 8px;
}
.rp-claim-item {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #555;
padding: 2px 0;
}
.rp-claim-item span:last-child {
color: #dc2626;
font-weight: bold;
}
/* 关闭按钮 */
#rp-close-btn {
position: absolute;
top: 10px;
right: 12px;
color: rgba(255, 255, 255, 0.7);
font-size: 20px;
cursor: pointer;
line-height: 1;
}
#rp-close-btn:hover {
color: #fff;
}
/* 状态提示 */
#rp-status-msg {
font-size: 12px;
text-align: center;
margin-top: 8px;
min-height: 16px;
color: #16a34a;
font-weight: bold;
}
</style>
{{-- 红包弹窗 DOM --}}
<div id="red-packet-modal">
<div id="red-packet-card">
{{-- 顶部标题区 --}}
<div id="rp-header">
<span id="rp-close-btn" onclick="closeRedPacketModal()"></span>
<span class="rp-emoji">🧧</span>
<div class="rp-sender" id="rp-sender-name">xxx 的礼包</div>
<div class="rp-title">聊天室专属礼包</div>
<div class="rp-amount"><small>总计 </small><span id="rp-total-amount">888</span><small id="rp-type-label">
金币</small></div>
</div>
{{-- 底部操作区 --}}
<div id="rp-body">
<div class="rp-info-row">
<span>剩余份数:<b id="rp-remaining">10</b> / <span id="rp-total-count">10</span> </span>
<span>倒计时:<b id="rp-countdown">120</b>s</span>
</div>
<div id="rp-timer-bar-wrap">
<div id="rp-timer-bar" style="width:100%;"></div>
</div>
<button id="rp-claim-btn" onclick="claimRedPacket()">🧧 立即抢红包</button>
<div id="rp-status-msg"></div>
{{-- 领取名单 --}}
<div id="rp-claims-list" style="display:none;">
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
<div id="rp-claims-items"></div>
</div>
</div>
</div>
</div>
<script>
/**
* 礼包红包前端交互模块
*
* 功能:
* 1. sendRedPacket() superlevel 点击「礼包」按钮后确认发包
* 2. showRedPacketModal() 收到 RedPacketSent 事件后弹出红包卡片
* 3. claimRedPacket() 用户点击「立即抢红包」
* 4. closeRedPacketModal() 关闭红包弹窗
* 5. WebSocket 监听 监听 red-packet.sent 广播事件
*/
(function() {
'use strict';
// 当前红包状态
let _rpEnvelopeId = null; // 当前红包 ID
let _rpExpireAt = null; // 过期时间戳(ms
let _rpTotalSeconds = 120; // 总倒计时秒数
let _rpTimer = null; // 倒计时定时器
let _rpClaimed = false; // 本次会话是否已领取
let _rpType = 'gold'; // 当前红包类型(gold / exp
// ── 发包确认 ───────────────────────────────────────
/**
* superlevel 点击「礼包」按钮,弹出 chatBanner 三按钮选择类型后发包。
*/
window.sendRedPacket = function() {
window.chatBanner.show({
icon: '🧧',
title: '发出礼包',
name: '选择礼包类型',
body: '将发出 <b>888</b> 数量共 <b>10</b> 份的礼包,系统凭空发放,房间成员先到先得!',
gradient: ['#991b1b', '#dc2626', '#ea580c'],
titleColor: '#fde68a',
autoClose: 0,
buttons: [{
label: '💰 金币礼包',
color: '#d97706',
onClick(btn, close) {
close();
doSendRedPacket('gold');
},
},
{
label: '✨ 经验礼包',
color: '#7c3aed',
onClick(btn, close) {
close();
doSendRedPacket('exp');
},
},
{
label: '取消',
color: 'rgba(255,255,255,0.15)',
onClick(btn, close) {
close();
},
},
],
});
};
/**
* 实际发包请求(由 chatBanner 按钮回调触发)。
*
* @param {'gold'|'exp'} type 货币类型
*/
async function doSendRedPacket(type) {
const btn = document.getElementById('red-packet-btn');
if (btn) {
btn.disabled = true;
btn.textContent = '发送中…';
}
try {
const res = await fetch('/command/red-packet/send', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
type
}),
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
await window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
}
// 成功后 WebSocket 广播 RedPacketSent,前端自动弹出红包卡片
} catch (e) {
await window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
} finally {
setTimeout(() => {
if (btn) {
btn.disabled = false;
btn.innerHTML = '🧧 礼包';
}
}, 3000);
}
}
/**
* 展示红包弹窗,并启动倒计时。
*
* @param {number} envelopeId 红包 ID
* @param {string} senderUsername 发包人用户名
* @param {number} totalAmount 总数量
* @param {number} totalCount 总份数
* @param {number} expireSeconds 有效秒数
* @param {'gold'|'exp'} type 货币类型
*/
window.showRedPacketModal = function(envelopeId, senderUsername, totalAmount, totalCount, expireSeconds,
type) {
try {
console.log('showRedPacketModal 触发,当前状态:', {
envelopeId,
senderUsername,
totalAmount,
totalCount,
expireSeconds,
type,
oldId: _rpEnvelopeId
});
_rpEnvelopeId = envelopeId;
_rpClaimed = false;
_rpTotalSeconds = expireSeconds;
_rpExpireAt = Date.now() + expireSeconds * 1000;
_rpType = type || 'gold'; // 保存类型供 claimRedPacket 使用
// 根据类型调整配色和标签
const isExp = (type === 'exp');
const typeIcon = isExp ? '✨' : '💰';
const typeName = isExp ? '经验' : '金币';
const headerBg = isExp ?
'linear-gradient(160deg,#6d28d9 0%,#5b21b6 50%,#4c1d95 100%)' :
'linear-gradient(160deg,#dc2626 0%,#b91c1c 50%,#991b1b 100%)';
const claimBg = isExp ?
'linear-gradient(135deg,#7c3aed,#4f46e5)' :
'linear-gradient(135deg,#dc2626,#ea580c)';
const modalEl = document.getElementById('red-packet-modal');
if (!modalEl) {
alert('致命错误:红包视图容器 #red-packet-modal 找不到!');
return;
}
// 强制解除隐藏,赋予超高权限层级
modalEl.style.setProperty('display', 'flex', 'important');
modalEl.style.setProperty('z-index', '9999999', 'important');
modalEl.style.setProperty('opacity', '1', 'important');
modalEl.style.setProperty('visibility', 'visible', 'important');
// 应用配色
document.getElementById('rp-header').style.background = headerBg;
const claimBtn = document.getElementById('rp-claim-btn');
if (claimBtn) {
claimBtn.style.background = claimBg;
}
// 填入数据
document.getElementById('rp-sender-name').textContent = senderUsername + ' 的礼包';
document.getElementById('rp-total-amount').textContent = totalAmount;
document.getElementById('rp-total-count').textContent = totalCount;
document.getElementById('rp-remaining').textContent = totalCount;
document.getElementById('rp-countdown').textContent = expireSeconds;
document.getElementById('rp-timer-bar').style.width = '100%';
document.getElementById('rp-status-msg').textContent = '';
document.getElementById('rp-claims-list').style.display = 'none';
document.getElementById('rp-claims-items').innerHTML = '';
// 更新卡片标题信息
const emojiEl = modalEl.querySelector('.rp-emoji');
if (emojiEl) emojiEl.textContent = typeIcon;
const titleEl = modalEl.querySelector('.rp-title');
if (titleEl) titleEl.textContent = typeName + '礼包';
const typeLabel = document.getElementById('rp-type-label');
if (typeLabel) typeLabel.textContent = ' ' + typeName;
if (claimBtn) {
claimBtn.disabled = false;
claimBtn.textContent = typeIcon + ' 立即抢包';
}
} catch (err) {
console.error('showRedPacketModal 执行失败:', err);
alert('红包弹窗初始化异常: ' + err.message);
}
// 启动倒计时
clearInterval(_rpTimer);
_rpTimer = setInterval(() => {
const remaining = Math.max(0, Math.ceil((_rpExpireAt - Date.now()) / 1000));
document.getElementById('rp-countdown').textContent = remaining;
document.getElementById('rp-timer-bar').style.width =
(remaining / _rpTotalSeconds * 100) + '%';
if (remaining <= 0) {
clearInterval(_rpTimer);
document.getElementById('rp-claim-btn').disabled = true;
document.getElementById('rp-claim-btn').textContent = '礼包已过期';
document.getElementById('rp-status-msg').style.color = '#9ca3af';
document.getElementById('rp-status-msg').textContent = '红包已过期。';
}
}, 1000);
// 异步拉取服务端最新状态(实时刷新剩余份数)
fetch(`/red-packet/${envelopeId}/status`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
return;
}
// 更新剩余份数显示
document.getElementById('rp-remaining').textContent = data.remaining_count;
// 若已过期 → 关闭弹窗 + Toast 提示
if (data.is_expired || data.envelope_status === 'expired') {
clearInterval(_rpTimer);
closeRedPacketModal();
window.chatToast?.show({
title: '⏰ 礼包已过期',
message: '该礼包已超过有效期,无法领取。',
icon: '⏰',
color: '#9ca3af',
duration: 4000,
});
return;
}
// 若已抢完 → 关闭弹窗 + Toast 提示
if (data.remaining_count <= 0 || data.envelope_status === 'completed') {
clearInterval(_rpTimer);
closeRedPacketModal();
window.chatToast?.show({
title: '😅 手慢了!',
message: '礼包已被抢完,下次要快一点哦!',
icon: '🧧',
color: '#f59e0b',
duration: 4000,
});
return;
}
// 若本人已领取 → 关闭弹窗 + Toast 提示
if (data.has_claimed) {
clearInterval(_rpTimer);
closeRedPacketModal();
window.chatToast?.show({
title: '✅ 已领取',
message: '您已成功领取过本次礼包!',
icon: '🧧',
color: '#10b981',
duration: 4000,
});
}
})
.catch(() => {}); // 静默忽略网络错误,不影响弹窗展示
};
// ── 抢包/关闭逻辑 ─────────────────────────────────────
window.closeRedPacketModal = function() {
console.trace('closeRedPacketModal 被调用');
document.getElementById('red-packet-modal').style.display = 'none';
if (_rpTimer) clearInterval(_rpTimer);
};
// 点击遮罩关闭
document.getElementById('red-packet-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeRedPacketModal();
}
});
// ── 抢红包 ──────────────────────────────────────
/**
* 用户点击「立即抢红包」,调用后端 claim 接口。
*/
window.claimRedPacket = async function() {
if (!_rpEnvelopeId) {
return;
}
const btn = document.getElementById('rp-claim-btn');
btn.disabled = true;
btn.textContent = '抢包中…';
try {
const res = await fetch(`/red-packet/${_rpEnvelopeId}/claim`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext.roomId
}),
});
const data = await res.json();
const statusEl = document.getElementById('rp-status-msg');
const typeLabel = (_rpType === 'exp') ? '经验' : '金币';
if (res.ok && data.status === 'success') {
_rpClaimed = true;
btn.textContent = '🎉 已抢到!';
statusEl.style.color = '#16a34a';
statusEl.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}`;
// 弹出全局 Toast
window.chatToast.show({
title: '🧧 礼包到账',
message: `恭喜您抢到了礼包 <b>${data.amount}</b> ${typeLabel}`,
icon: '🧧',
color: (_rpType === 'exp') ? '#7c3aed' : '#dc2626',
duration: 8000,
});
// 3 秒后自动关闭弹窗
setTimeout(() => closeRedPacketModal(), 3000);
} else {
statusEl.style.color = '#dc2626';
statusEl.textContent = data.message || '抢包失败';
// 若已领过或已抢完则禁用按钮,否则解除禁用以重试
if (data.message && (data.message.includes('已经领过') || data.message.includes('已被抢完') ||
data.message.includes('已抢完'))) {
btn.textContent = '已参与';
} else {
btn.disabled = false;
btn.textContent = '🧧 立即抢红包';
}
}
} catch (e) {
document.getElementById('rp-status-msg').textContent = '网络异常,请重试';
document.getElementById('rp-status-msg').style.color = '#dc2626';
btn.disabled = false;
btn.textContent = '🧧 立即抢红包';
}
};
// ── 更新领取名单(被 WS 触发调用)───────────────
/**
* 收到「系统传音」中的领取播报时,同步更新弹窗内的名单与剩余数。
*
* @param {string} username 领取者用户名
* @param {number} amount 领取金额
* @param {number} remaining 剩余份数
*/
window.updateRedPacketClaimsUI = function(username, amount, remaining) {
const remainingEl = document.getElementById('rp-remaining');
if (remainingEl) {
remainingEl.textContent = remaining;
}
const listEl = document.getElementById('rp-claims-list');
const itemsEl = document.getElementById('rp-claims-items');
if (!listEl || !itemsEl) {
return;
}
listEl.style.display = 'block';
const item = document.createElement('div');
item.className = 'rp-claim-item';
item.innerHTML = `<span>${username}</span><span>+${amount} 金币</span>`;
itemsEl.prepend(item);
// 若已全部领完,更新按钮状态
if (remaining <= 0) {
const btn = document.getElementById('rp-claim-btn');
if (btn && !_rpClaimed) {
btn.disabled = true;
btn.textContent = '礼包已被抢完!';
}
clearInterval(_rpTimer);
// 3 秒后自动关闭
setTimeout(() => closeRedPacketModal(), 3000);
}
};
// ── WebSocket 监听 red-packet.sent ───────────────
/**
* 等待 Echo 就绪后注册 red-packet.sent 事件监听,
* 每次收到新红包时弹出红包卡片弹窗。
*/
function setupRedPacketListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRedPacketListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen('.red-packet.sent', (e) => {
// 收到红包事件,弹出卡片(type 决定金币/经验配色)
showRedPacketModal(
e.envelope_id,
e.sender_username,
e.total_amount,
e.total_count,
e.expire_seconds,
e.type || 'gold',
);
});
console.log('RedPacketSent 监听器已注册');
}
document.addEventListener('DOMContentLoaded', setupRedPacketListener);
})(); // end IIFE
</script>
@@ -0,0 +1,542 @@
{{--
文件功能:老虎机游戏前台面板组件
聊天室内老虎机游戏:
- 悬浮按钮 🎰 入口(游戏开启时显示)
- 三列滚轮动画(CSS 逐列延迟停止)
- 权重随机图案、多种赔率(三7全服广播)
- 每日次数限制、金币余额显示
- 最近记录展示
--}}
{{-- \u2500\u2500\u2500 \u8001\u864e\u673a\u4e3b\u9762\u677f \u2500\u2500\u2500 --}}
<div id="slot-panel" x-data="slotPanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9950;
display:flex; align-items:center; justify-content:center;">
<div
style="width:420px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- \u2500\u2500\u2500 \u6807\u9898\u680f\uff08\u6d77\u519b\u84dd\u98ce\u683c\uff09\u2500\u2500\u2500 --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; display:flex; align-items:center; gap:10px;">
<div style="font-size:14px; font-weight:bold; color:#fff; flex:1;">🎰 老虎机</div>
<div
style="font-size:12px; color:rgba(255,255,255,.8); background:rgba(0,0,0,.15); padding:2px 10px; border-radius:10px; display:flex; align-items:center; gap:4px;">
💰 <span x-text="Number(balance).toLocaleString()" style="color:#fef08a; font-weight:bold;"></span>
</div>
<div x-show="dailyLimit > 0"
style="font-size:11px; color:rgba(255,255,255,.7); background:rgba(255,255,255,.15); padding:2px 8px; border-radius:10px;"
x-text="'剩余 ' + remaining + ' 次'"></div>
<span onclick="Alpine.$data(document.getElementById('slot-panel')).close()"
style="cursor:pointer; font-size:18px; color:rgba(255,255,255,.8); line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- \u2500\u2500\u2500 \u8de8\u8d39\u8bf4\u660e\u6761 \u2500\u2500\u2500 --}}
<div
style="background:#f6faff; border-bottom:1px solid #d0e4f5; padding:4px 16px; font-size:11px; color:#5a8fc0; text-align:center;">
每次消耗 <span x-text="costPerSpin" style="color:#336699; font-weight:bold;"></span> 金币
</div>
{{-- \u2500\u2500\u2500 \u4e3b\u4f53\u5185\u5bb9\uff08\u767d\u5e95\uff09\u2500\u2500\u2500 --}}
<div style="background:#fff; padding:16px 16px 12px;">
{{-- \u4e09\u5217\u8f6c\u8f6e --}}
<div
style="background:#f0f6ff; border-radius:10px; padding:14px 12px; margin-bottom:14px;
border:1px solid #d0e4f5; box-shadow:inset 0 2px 6px rgba(51,102,153,.06);">
<div style="display:flex; gap:8px; justify-content:center; align-items:center;">
{{-- 第一列 --}}
<div
style="flex:1; background:#fff; border-radius:8px;
height:120px; display:flex; align-items:center; justify-content:center;
border:1.5px solid #b8d0e8; box-shadow:0 2px 8px rgba(51,102,153,.1);">
<div id="slot-reel-0" x-text="spinning ? spinEmojis[0] : resultEmojis[0]"
:style="'font-size:80px; line-height:1; transition:all .15s; user-select:none; ' + (spinning &&
!reel1Stopped ? 'animation:reel-spin .1s linear infinite' : '')">
</div>
</div>
{{-- 分隔 --}}
<div style="color:#b8d0e8; font-size:20px; font-weight:900;">|</div>
{{-- 第二列 --}}
<div
style="flex:1; background:#fff; border-radius:8px;
height:120px; display:flex; align-items:center; justify-content:center;
border:1.5px solid #b8d0e8; box-shadow:0 2px 8px rgba(51,102,153,.1);">
<div id="slot-reel-1" x-text="spinning ? spinEmojis[1] : resultEmojis[1]"
:style="'font-size:80px; line-height:1; transition:all .15s; user-select:none; ' + (spinning &&
!reel2Stopped ? 'animation:reel-spin .12s linear infinite' : '')">
</div>
</div>
{{-- 分隔 --}}
<div style="color:#b8d0e8; font-size:20px; font-weight:900;">|</div>
{{-- 第三列 --}}
<div
style="flex:1; background:#fff; border-radius:8px;
height:120px; display:flex; align-items:center; justify-content:center;
border:1.5px solid #b8d0e8; box-shadow:0 2px 8px rgba(51,102,153,.1);">
<div id="slot-reel-2" x-text="spinning ? spinEmojis[2] : resultEmojis[2]"
:style="'font-size:80px; line-height:1; transition:all .15s; user-select:none; ' + (spinning &&
!reel3Stopped ? 'animation:reel-spin .14s linear infinite' : '')">
</div>
</div>
</div>
{{-- 中间射线指示条 --}}
<div
style="height:2px; background:linear-gradient(90deg,transparent,rgba(51,102,153,.3),transparent);
margin-top:10px; border-radius:1px;">
</div>
</div>
{{-- \u7ed3\u679c\u63d0\u793a --}}
<div style="text-align:center; min-height:34px; margin-bottom:10px;">
<div x-show="!spinning && resultLabel" x-transition
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' ?
'background:linear-gradient(135deg,#7c3aed,#a78bfa); color:#fff; box-shadow:0 0 12px rgba(124,58,237,.3);' :
resultType === 'triple' ?
'background:linear-gradient(135deg,#059669,#34d399); color:#fff; box-shadow:0 0 10px rgba(52,211,153,.3);' :
resultType === 'pair' ?
'background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 0 10px rgba(51,102,153,.2);' :
resultType === 'curse' ?
'background:linear-gradient(135deg,#dc2626,#ef4444); color:#fff; box-shadow:0 0 10px rgba(220,38,38,.3);' :
'background:#f0f6ff; color:#5a8fc0; border:1px solid #d0e4f5;'"
x-text="resultLabel">
</div>
<div x-show="spinning" style="color:#5a8fc0; font-size:13px; animation:blink .6s infinite;">
正在转动中…
</div>
</div>
{{-- \u76c8\u4e8f\u663e\u793a --}}
<div x-show="!spinning && resultType" style="text-align:center; margin-bottom:12px;">
<div x-show="netChange > 0" style="color:#16a34a; font-size:22px; font-weight:bold;"
x-text="'+' + Number(netChange).toLocaleString() + ' 💰'">
</div>
<div x-show="netChange < 0" style="color:#dc2626; font-size:16px; font-weight:bold;"
x-text="Number(netChange).toLocaleString() + ' 💰'">
</div>
<div x-show="netChange === 0 && resultType === 'miss'" style="color:#999; font-size:13px;">
损失 <span x-text="costPerSpin"></span> 金币
</div>
</div>
{{-- \u65cb\u8f6c\u6309\u94ae --}}
<button x-on:click="doSpin()" :disabled="spinning || (dailyLimit > 0 && remaining <= 0)"
style="display:block; width:100%; border:none; border-radius:12px; padding:12px 0;
font-size:15px; font-weight:bold; cursor:pointer; transition:all .2s; letter-spacing:1px;"
:style="(spinning || (dailyLimit > 0 && remaining <= 0)) ? {
background: '#e0e8f0',
color: '#99a8b8',
cursor: 'not-allowed',
boxShadow: 'none'
} : {
background: 'linear-gradient(135deg,#336699,#5a8fc0)',
color: '#fff',
boxShadow: '0 4px 14px rgba(51,102,153,.35)'
}">
<span
x-text="spinning ? '🎰 转动中…' :
(dailyLimit > 0 && remaining <= 0) ? '今日次数已用完 🔒' :
'🎰 SPIN'"></span>
</button>
{{-- \u8d54\u7387\u8bf4\u660e --}}
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-top:12px;">
<div
style="background:#fffbeb; border:1px solid #fde68a; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>7️⃣7️⃣7️⃣</div>
<div style="color:#d97706; font-weight:bold; margin-top:2px;">×100</div>
</div>
<div
style="background:#f5f3ff; border:1px solid #ddd6fe; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>💎💎💎</div>
<div style="color:#7c3aed; font-weight:bold; margin-top:2px;">×50</div>
</div>
<div
style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>三同</div>
<div style="color:#059669; font-weight:bold; margin-top:2px;">×10</div>
</div>
<div
style="background:#f0f6ff; border:1px solid #d0e4f5; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>两同</div>
<div style="color:#336699; font-weight:bold; margin-top:2px;">×2</div>
</div>
</div>
{{-- 玩法说明(可折叠) --}}
<div x-data="{ open: false }" style="margin-top:12px;">
<button @click="open = !open"
style="width:100%; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
padding:7px 12px; font-size:11px; color:#5a8fc0; cursor:pointer; transition:all .15s;
display:flex; align-items:center; justify-content:space-between;"
onmouseover="this.style.background='#eaf3ff'" onmouseout="this.style.background='#f6faff'">
<span>📖 玩法说明</span>
<span x-text="open ? '▲ 收起' : '▼ 展开'" style="font-size:10px; color:#99b0cc;"></span>
</button>
<div x-show="open" x-transition
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>
<div> 图案组合决定奖励倍率,奖励金币 = 消耗金币 × 倍率</div>
<div> 每日有次数上限,用完须等次日 0 点重置</div>
<div style="height:1px; background:#d0e4f5; margin:8px 0;"></div>
<div style="font-weight:bold; color:#336699; margin-bottom:6px; font-size:12px;">💎 图案赔率表</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:4px;">
<div>🎰 7️⃣7️⃣7️⃣ <strong style="color:#d97706;">×100</strong> 大奖全服广播</div>
<div>💎💎💎 <strong style="color:#7c3aed;">×50</strong> 三钻大奖</div>
<div>任意三同 <strong style="color:#059669;">×10</strong> 三倍以上</div>
<div>任意两同 <strong style="color:#336699;">×2</strong> 回本加成</div>
<div>💀 骷髅 <strong style="color:#dc2626;">另有惩罚</strong></div>
<div>其余组合 <strong style="color:#999;">未中奖</strong></div>
</div>
<div style="height:1px; background:#d0e4f5; margin:8px 0;"></div>
<div style="font-weight:bold; color:#336699; margin-bottom:4px; font-size:12px;">⚠️ 注意事项</div>
<div> 奖励直接到账金币,无需额外领取</div>
<div> 三个 7️⃣ 为聊天室大奖,系统会全服广播</div>
<div> 未中奖只损失本次消耗的旋转费用</div>
</div>
</div>
{{-- 历史记录 --}}
<div x-show="history.length > 0" style="margin-top:12px;">
<div style="color:#99b0cc; font-size:10px; margin-bottom:5px;">最近记录</div>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<template x-for="h in history" :key="h.created_at + h.result_label">
<div style="background:#f0f6ff; border:1px solid #d0e4f5; border-radius:6px; padding:3px 8px;
font-size:11px; display:flex; align-items:center; gap:4px;"
:title="h.result_label + ' ' + (h.payout > 0 ? '+' : '') + h.payout">
<span x-text="h.emojis.join('')"></span>
<span :style="h.payout > 0 ? 'color:#16a34a; font-weight:bold;' : 'color:#dc2626;'"
x-text="(h.payout > 0 ? '+' : '') + h.payout"></span>
</div>
</template>
</div>
</div>
</div>
{{-- \u2500\u2500\u2500 \u5e95\u90e8\u5173\u95ed \u2500\u2500\u2500 --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:14px 16px; display:flex; justify-content:center;">
<button x-on:click="close()"
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
</div>
</div>
</div>
<style>
@keyframes slot-pulse {
0%,
100% {
box-shadow: 0 4px 20px rgba(245, 158, 11, .5);
}
50% {
box-shadow: 0 4px 30px rgba(245, 158, 11, .9);
}
}
@keyframes reel-spin {
0% {
transform: translateY(-4px);
}
50% {
transform: translateY(4px);
}
100% {
transform: translateY(-4px);
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style>
<script>
/**
* 老虎机悬浮按钮 Alpine 组件(检查游戏是否开启)
*/
function slotFab() {
const STORAGE_KEY = 'slot_fab_pos';
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return {
visible: false,
posX: saved?.x ?? 18,
posY: saved?.y ?? 150,
dragging: false,
_startX: 0,
_startY: 0,
_origX: 0,
_origY: 0,
_moved: false,
async init() {
try {
const res = await fetch('/slot/info');
const data = await res.json();
this.visible = data.enabled === true;
} catch {}
},
startDrag(e) {
this.dragging = true;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
e.currentTarget.setPointerCapture?.(e.pointerId);
},
onDrag(e) {
if (!this.dragging) return;
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
this.posX = Math.max(4, Math.min(window.innerWidth - 60, this._origX - dx));
this.posY = Math.max(4, Math.min(window.innerHeight - 60, this._origY + dy));
},
endDrag(e) {
if (!this.dragging) return;
this.dragging = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify({
x: this.posX,
y: this.posY
}));
if (!this._moved) this.openPanel();
},
openPanel() {
const panel = document.getElementById('slot-panel');
if (panel) Alpine.$data(panel).open();
},
};
}
/**
* 老虎机主面板 Alpine 组件
*/
function slotPanel() {
// 所有图案的 emoji 数组,与服务端权重一致(用于转动时随机展示)
const ALL_EMOJIS = ['🍒', '🍋', '🍊', '🍇', '🔔', '💎', '💀', '7️⃣'];
return {
show: false,
// 配置
costPerSpin: 100,
dailyLimit: 0,
remaining: null,
balance: 0,
// 转轮状态
spinning: false,
reel1Stopped: false,
reel2Stopped: false,
reel3Stopped: false,
spinEmojis: ['🎰', '🎰', '🎰'],
resultEmojis: ['❓', '❓', '❓'],
// 结果
resultType: '',
resultLabel: '',
netChange: 0,
// 历史
history: [],
// 动画定时器
_spinInterval: null,
_stopTimers: [],
/**
* Alpine 初始化: 监听 show 变化自动加载数据(解决从游戏大厅入口不调用 open() 时历史不刷新的问题)
*/
init() {
this.$watch('show', async (val) => {
if (val) {
await this.loadInfo();
await this.loadHistory();
}
});
},
/**
* 打开面板并加载数据
*/
async open() {
this.show = true;
await this.loadInfo();
await this.loadHistory();
},
/**
* 加载游戏配置和余额
*/
async loadInfo() {
try {
const res = await fetch('/slot/info');
const data = await res.json();
if (!data.enabled) {
this.show = false;
return;
}
this.costPerSpin = data.cost_per_spin;
this.dailyLimit = data.daily_limit;
this.remaining = data.remaining;
// 从聊天室全局变量读取余额
this.balance = window.__chatUser?.jjb ?? 0;
} catch {}
},
/**
* 加载历史记录
*/
async loadHistory() {
try {
const res = await fetch('/slot/history');
const data = await res.json();
this.history = data.history || [];
} catch {}
},
/**
* 执行转动
*/
async doSpin() {
if (this.spinning) return;
if (this.dailyLimit > 0 && this.remaining <= 0) return;
this.spinning = true;
this.resultType = '';
this.resultLabel = '';
this.netChange = 0;
this.reel1Stopped = false;
this.reel2Stopped = false;
this.reel3Stopped = false;
// 开始随机滚动动画
this._spinInterval = setInterval(() => {
this.spinEmojis = [
ALL_EMOJIS[Math.floor(Math.random() * ALL_EMOJIS.length)],
ALL_EMOJIS[Math.floor(Math.random() * ALL_EMOJIS.length)],
ALL_EMOJIS[Math.floor(Math.random() * ALL_EMOJIS.length)],
];
}, 80);
// 请求后端
let data;
try {
const res = await fetch('/slot/spin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
});
data = await res.json();
} catch {
clearInterval(this._spinInterval);
this.spinning = false;
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
return;
}
if (!data.ok) {
clearInterval(this._spinInterval);
this.spinning = false;
window.chatDialog?.alert(data.message || '转动失败', '提示', '#ef4444');
return;
}
// 逐列停止(延迟效果)
const stopReel = (reelIndex, emoji, delay) => {
return new Promise(resolve => setTimeout(() => {
clearInterval(this._spinInterval);
this['reel' + (reelIndex + 1) + 'Stopped'] = true;
this.spinEmojis = [...this.spinEmojis];
this.spinEmojis[reelIndex] = emoji;
resolve();
}, delay));
};
await stopReel(0, data.emojis[0], 600);
this._spinInterval = setInterval(() => {
this.spinEmojis = [data.emojis[0],
ALL_EMOJIS[Math.floor(Math.random() * ALL_EMOJIS.length)],
ALL_EMOJIS[Math.floor(Math.random() * ALL_EMOJIS.length)]
];
}, 80);
await stopReel(1, data.emojis[1], 500);
this._spinInterval = setInterval(() => {
this.spinEmojis = [data.emojis[0], data.emojis[1],
ALL_EMOJIS[Math.floor(Math.random() * ALL_EMOJIS.length)]
];
}, 80);
await stopReel(2, data.emojis[2], 400);
clearInterval(this._spinInterval);
// 显示结果
this.resultEmojis = data.emojis;
this.resultType = data.result_type;
this.resultLabel = data.result_label;
this.netChange = data.payout > 0 ? data.payout - this.costPerSpin : -this.costPerSpin + (data
.payout < 0 ? data.payout : 0);
this.balance = data.balance;
this.spinning = false;
// 更新全局余额(让聊天界面同步)
if (window.__chatUser) window.__chatUser.jjb = data.balance;
if (this.dailyLimit > 0 && this.remaining !== null) {
this.remaining = Math.max(0, this.remaining - 1);
}
await this.loadHistory();
},
/**
* 关闭面板
*/
close() {
clearInterval(this._spinInterval);
this.spinning = false;
this.show = false;
},
};
}
</script>