重构(chat): 聊天室 Partials 第二阶段分类拆分及修复红包弹窗隐藏 Bug
- 完成对 scripts.blade.php 中非核心业务逻辑(钓鱼游戏、AI机器人、系统全局公告)的深度抽象隔离 - 修复抢红包逻辑中 setInterval 缺失时间参数(1000)引发浏览器前端主线程挂起的重度阻塞问题 - 修复 lottery-panel 组件结尾漏写 </div> 导致的连锁级渲染树崩溃(该崩溃导致红包节点被意外当作隐藏后代节点渲染,造成彻底不可见) - 对相关模板规范代码结构,执行 Laravel Pint 格式化并提交
This commit is contained in:
@@ -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">×</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">×</span>
|
||||
</div>
|
||||
|
||||
{{-- ─── 游戏卡片网格 ─── --}}
|
||||
<div style="flex:1; overflow-y:auto; background:#f6faff; padding:12px;">
|
||||
|
||||
{{-- 加载状态 --}}
|
||||
<div id="game-hall-loading" style="text-align:center; color:#336699; padding:40px 0; font-size:13px;">
|
||||
<div style="font-size:28px; margin-bottom:8px;">⏳</div>
|
||||
加载游戏状态中…
|
||||
</div>
|
||||
|
||||
{{-- 游戏卡片容器 --}}
|
||||
<div id="game-hall-cards" style="display:none; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
</div>
|
||||
|
||||
{{-- 全部未开启提示 --}}
|
||||
<div id="game-hall-empty"
|
||||
style="display:none; text-align:center; color:#336699; padding:40px 0; font-size:13px;">
|
||||
<div style="font-size:28px; margin-bottom:8px;">🔒</div>
|
||||
暂无开启的游戏,请联系管理员
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 底部 ─── --}}
|
||||
<div
|
||||
style="background:#fff; border-top:1px solid #d0e4f5; padding:8px 16px;
|
||||
display:flex; justify-content:center; flex-shrink:0;">
|
||||
<button onclick="closeGameHall()"
|
||||
style="padding:5px 24px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:4px;
|
||||
font-size:12px; color:#336699; cursor:pointer; transition:all .15s;"
|
||||
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 游戏大厅模块(使用 IIFE 隔离全局作用域,防止 const 重复初始化导致脚本块失败)
|
||||
*/
|
||||
(function() {
|
||||
/** 游戏大厅配置定义(ID → 展示配置) */
|
||||
const GAME_HALL_GAMES = [{
|
||||
id: 'baccarat',
|
||||
name: '🎲 百家乐',
|
||||
desc: '猜骰子大小,1:1 赔率,豹子 1:24',
|
||||
accentColor: '#336699',
|
||||
fetchUrl: '/baccarat/current',
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
const panel = document.getElementById('baccarat-panel');
|
||||
if (panel) Alpine.$data(panel).show = true;
|
||||
},
|
||||
renderStatus: (data) => {
|
||||
if (!data?.round) return {
|
||||
badge: '⏸ 等待开局',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '下局即将开始,稍后再来'
|
||||
};
|
||||
const r = data.round;
|
||||
if (r.status === 'betting') {
|
||||
return {
|
||||
badge: '🟢 押注中',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number((r.total_bet_big||0)+(r.total_bet_small||0)+(r.total_bet_triple||0)).toLocaleString()} 金`
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: '⏳ 开奖中',
|
||||
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
|
||||
detail: '正在摇骰子…'
|
||||
};
|
||||
},
|
||||
btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情',
|
||||
},
|
||||
{
|
||||
id: 'slot_machine',
|
||||
name: '🎰 老虎机',
|
||||
desc: '每日限额旋转,中奖即时到账',
|
||||
accentColor: '#0891b2',
|
||||
fetchUrl: null,
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
const panel = document.getElementById('slot-panel');
|
||||
if (panel) Alpine.$data(panel).show = true;
|
||||
},
|
||||
renderStatus: () => ({
|
||||
badge: '✅ 随时可玩',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: '每日限额抽奖,旋转即可'
|
||||
}),
|
||||
btnLabel: () => '🎰 开始旋转',
|
||||
},
|
||||
{
|
||||
id: 'mystery_box',
|
||||
name: '📦 神秘箱子',
|
||||
desc: '管理员随机投放,抢到即开奖',
|
||||
accentColor: '#b45309',
|
||||
fetchUrl: '/mystery-box/status',
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
window.dispatchEvent(new CustomEvent('open-mystery-box'));
|
||||
},
|
||||
renderStatus: (data) => {
|
||||
const count = data?.available_count ?? 0;
|
||||
return count > 0 ? {
|
||||
badge: `🎁 ${count} 个待领`,
|
||||
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
|
||||
detail: '箱子已投放!快去领取'
|
||||
} : {
|
||||
badge: '📭 暂无箱子',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '等待管理员投放'
|
||||
};
|
||||
},
|
||||
btnLabel: (data) => (data?.available_count ?? 0) > 0 ? '🎁 立即领取' : '📭 等待投放',
|
||||
},
|
||||
{
|
||||
id: 'horse_racing',
|
||||
name: '🐎 赛马竞猜',
|
||||
desc: '彩池制赛马,押注马匹赢取奖金',
|
||||
accentColor: '#336699',
|
||||
fetchUrl: '/horse-race/current',
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
const panel = document.getElementById('horse-race-panel');
|
||||
if (panel) Alpine.$data(panel).openFromHall();
|
||||
},
|
||||
renderStatus: (data) => {
|
||||
if (!data?.race) return {
|
||||
badge: '⏸ 等待开赛',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '下场赛马即将开始'
|
||||
};
|
||||
const r = data.race;
|
||||
if (r.status === 'betting') {
|
||||
return {
|
||||
badge: '🟢 押注中',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number(r.total_pool || 0).toLocaleString()} 金`
|
||||
};
|
||||
}
|
||||
if (r.status === 'running') {
|
||||
return {
|
||||
badge: '🏇 跑马中',
|
||||
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
|
||||
detail: '比赛进行中…'
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: '🏆 已结算',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '下场即将开始'
|
||||
};
|
||||
},
|
||||
btnLabel: (data) => data?.race?.status === 'betting' ? '🐎 立即押注' : '📊 查看赛况',
|
||||
},
|
||||
{
|
||||
id: 'fortune_telling',
|
||||
name: '🔮 神秘占卜',
|
||||
desc: '每日签文,开启今日运势加成',
|
||||
accentColor: '#6d28d9',
|
||||
fetchUrl: '/fortune/today',
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
const panel = document.getElementById('fortune-panel');
|
||||
if (panel) Alpine.$data(panel).show = true;
|
||||
},
|
||||
renderStatus: (data) => {
|
||||
if (!data?.enabled) return {
|
||||
badge: '🔒 未开启',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '此游戏暂未开启'
|
||||
};
|
||||
const used = data.free_used ?? 0;
|
||||
const total = data.free_count ?? 1;
|
||||
return data.has_free_left ? {
|
||||
badge: '✨ 免费可占',
|
||||
badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd',
|
||||
detail: `今日已占 ${used}/${total} 次,还有免费次数`
|
||||
} : {
|
||||
badge: '💰 付费可占',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: `今日免费次数已用完(${data.extra_cost} 金/次)`
|
||||
};
|
||||
},
|
||||
btnLabel: (data) => data?.has_free_left ? '🔮 免费占卜' : '🔮 付费占卜',
|
||||
},
|
||||
{
|
||||
id: 'fishing',
|
||||
name: '🎣 钓鱼',
|
||||
desc: '消耗鱼饵钓取金币和道具。背包需有鱼饵才能出竿。',
|
||||
accentColor: '#0d9488',
|
||||
fetchUrl: null,
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
// 直接触发钓鱼,无需手动输入指令
|
||||
if (typeof startFishing === 'function') {
|
||||
startFishing();
|
||||
}
|
||||
},
|
||||
renderStatus: () => ({
|
||||
badge: '🎣 随时可钓',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: '① 点击发言框上方【🎣 钓鱼】按钮 → ② 等待浮漂出现 → ③ 看到 🪝 后立刻点击收竿!'
|
||||
}),
|
||||
btnLabel: () => '🎣 去钓鱼',
|
||||
},
|
||||
{
|
||||
id: 'lottery',
|
||||
name: '🎟️ 双色球',
|
||||
desc: '每日20:00开奖,选3红1蓝,按奖池比例派奖,无一等奖滚存累积',
|
||||
accentColor: '#dc2626',
|
||||
fetchUrl: '/lottery/current',
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
if (typeof openLotteryPanel === 'function') openLotteryPanel();
|
||||
},
|
||||
renderStatus: (data) => {
|
||||
if (!data?.issue) return {
|
||||
badge: '⏸ 等待开期',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '暂无进行中期次'
|
||||
};
|
||||
const iss = data.issue;
|
||||
const pool = Number(iss.pool_amount || 0).toLocaleString();
|
||||
if (data.is_open) {
|
||||
const h = Math.floor(iss.seconds_left / 3600);
|
||||
const m = Math.floor((iss.seconds_left % 3600) / 60);
|
||||
const timeStr = h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
return {
|
||||
badge: iss.is_super_issue ? '🎊 超级期购票中' : '🟢 购票中',
|
||||
badgeStyle: 'background:#fef2f2; color:#b91c1c; border:1px solid #fca5a5',
|
||||
detail: `💰 奖池 ${pool} 金 | 距开奖 ${timeStr} | 第 ${iss.issue_no} 期`
|
||||
};
|
||||
}
|
||||
if (iss.status === 'settled') return {
|
||||
badge: '✅ 已开奖',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: '本期已开奖,下期购票中'
|
||||
};
|
||||
return {
|
||||
badge: '🔴 已停售',
|
||||
badgeStyle: 'background:#fee2e2; color:#b91c1c; border:1px solid #fecaca',
|
||||
detail: `💰 奖池 ${pool} 金 | 等待开奖中…`
|
||||
};
|
||||
},
|
||||
btnLabel: (data) => data?.is_open ? '🎟️ 立即购票' : '📊 查看结果',
|
||||
},
|
||||
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
* 打开游戏大厅弹窗,加载各游戏状态
|
||||
*/
|
||||
window.openGameHall = async function() {
|
||||
const modal = document.getElementById('game-hall-modal');
|
||||
if (!modal) {
|
||||
console.error('[游戏大厅] game-hall-modal 元素不存在,请检查模板加载');
|
||||
return;
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
document.getElementById('game-hall-loading').style.display = 'block';
|
||||
document.getElementById('game-hall-cards').style.display = 'none';
|
||||
document.getElementById('game-hall-empty').style.display = 'none';
|
||||
|
||||
const jjbEl = document.getElementById('game-hall-jjb');
|
||||
if (jjbEl && window.chatContext?.userJjb !== undefined) {
|
||||
jjbEl.textContent = Number(window.chatContext.userJjb).toLocaleString();
|
||||
}
|
||||
|
||||
// 每次打开均实时拉取后台开关状态(避免页面不刷新时开关不同步)
|
||||
let enabledMap = window.GAME_ENABLED ?? {};
|
||||
try {
|
||||
const r = await fetch('/games/enabled', {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (r.ok) enabledMap = await r.json();
|
||||
} catch {
|
||||
/* 网络异常时降级使用页面注入值 */
|
||||
}
|
||||
|
||||
// 过滤出后台已开启的游戏
|
||||
const enabledGames = GAME_HALL_GAMES.filter(g => enabledMap[g.id] !== false);
|
||||
|
||||
// 并行请求有状态接口的游戏
|
||||
const statuses = {};
|
||||
await Promise.all(
|
||||
enabledGames.filter(g => g.fetchUrl).map(async g => {
|
||||
try {
|
||||
const res = await fetch(g.fetchUrl);
|
||||
statuses[g.id] = await res.json();
|
||||
} catch {
|
||||
statuses[g.id] = null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
renderGameCards(enabledGames, statuses);
|
||||
} catch (err) {
|
||||
console.error('[游戏大厅] 渲染游戏卡片失败:', err);
|
||||
document.getElementById('game-hall-loading').style.display = 'none';
|
||||
document.getElementById('game-hall-empty').style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭游戏大厅弹窗
|
||||
*/
|
||||
window.closeGameHall = function() {
|
||||
document.getElementById('game-hall-modal').style.display = 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染所有游戏卡片(海军蓝风格)
|
||||
*
|
||||
* @param {Array} games 已过滤的游戏配置列表
|
||||
* @param {Object} statuses 各游戏的 API 返回数据
|
||||
*/
|
||||
function renderGameCards(games, statuses) {
|
||||
const container = document.getElementById('game-hall-cards');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (games.length === 0) {
|
||||
document.getElementById('game-hall-loading').style.display = 'none';
|
||||
document.getElementById('game-hall-empty').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
games.forEach(game => {
|
||||
try {
|
||||
const data = statuses[game.id] ?? null;
|
||||
const status = game.renderStatus ? game.renderStatus(data) : {
|
||||
badge: '✅ 可用',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: ''
|
||||
};
|
||||
const btnLabel = game.btnLabel ? game.btnLabel(data) : '🎮 进入';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = `
|
||||
background:#fff;
|
||||
border:1px solid #d0e4f5;
|
||||
border-left:4px solid ${game.accentColor};
|
||||
border-radius:6px; padding:12px 14px;
|
||||
cursor:default; transition:border-color .2s, box-shadow .2s;
|
||||
display:flex; flex-direction:column; gap:8px;
|
||||
`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
|
||||
<div style="flex:1;">
|
||||
<div style="color:#225588; font-weight:bold; font-size:13px; margin-bottom:3px;">${game.name}</div>
|
||||
<div style="color:#666; font-size:11px; line-height:1.4;">${game.desc}</div>
|
||||
</div>
|
||||
<span style="padding:2px 8px; border-radius:10px; font-size:10px; font-weight:bold; white-space:nowrap; ${status.badgeStyle}">
|
||||
${status.badge}
|
||||
</span>
|
||||
</div>
|
||||
<div style="color:#888; font-size:10px; line-height:1.4; min-height:14px; border-top:1px dashed #e0ecf8; padding-top:6px;">${status.detail || ' '}</div>
|
||||
<button
|
||||
style="width:100%; border:none; border-radius:4px; padding:7px 8px; font-size:12px; font-weight:bold;
|
||||
cursor:pointer; color:#fff; transition:opacity .15s;
|
||||
background:linear-gradient(135deg,${game.accentColor},${game.accentColor}cc);"
|
||||
onmouseover="this.style.opacity='.85'"
|
||||
onmouseout="this.style.opacity='1'">
|
||||
${btnLabel}
|
||||
</button>
|
||||
`;
|
||||
|
||||
card.querySelector('button').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
game.openFn();
|
||||
});
|
||||
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.borderColor = game.accentColor;
|
||||
card.style.boxShadow = `0 2px 8px rgba(51,102,153,.18)`;
|
||||
});
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.borderColor = '#d0e4f5';
|
||||
card.style.borderLeftColor = game.accentColor;
|
||||
card.style.boxShadow = '';
|
||||
});
|
||||
|
||||
container.appendChild(card);
|
||||
} catch (err) {
|
||||
// 单个游戏卡片渲染失败不影响其他游戏展示
|
||||
console.warn(`[游戏大厅] 游戏 ${game.id} 卡片渲染失败:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('game-hall-loading').style.display = 'none';
|
||||
container.style.display = 'grid';
|
||||
}
|
||||
|
||||
// 点击遮罩关闭弹窗
|
||||
document.getElementById('game-hall-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeGameHall();
|
||||
});
|
||||
|
||||
})(); // end IIFE
|
||||
</script>
|
||||
@@ -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">×</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> 期 |
|
||||
<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">×</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;'">
|
||||
+ 加入购物车
|
||||
</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.chatBanner(chat-banner.blade.php)、window.chatDialog、window.chatToast。
|
||||
|
||||
从 scripts.blade.php 提取,与其他游戏面板(baccarat-panel、slot-machine 等)保持统一结构。
|
||||
|
||||
@author ChatRoom Laravel
|
||||
@version 1.0.0
|
||||
--}}
|
||||
<style>
|
||||
/* 红包弹窗遮罩 */
|
||||
#red-packet-modal {
|
||||
display: none;
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
z-index: 10500 !important;
|
||||
background: rgba(0, 0, 0, 0.6) !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
animation: rpFadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes rpFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 红包卡片主体 */
|
||||
#red-packet-card {
|
||||
width: 300px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(220, 38, 38, 0.4);
|
||||
animation: rpCardIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes rpCardIn {
|
||||
from {
|
||||
transform: scale(0.7) translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 红包顶部区 */
|
||||
#rp-header {
|
||||
background: linear-gradient(160deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%);
|
||||
padding: 24px 20px 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#rp-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(255, 100, 0, 0.3) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rp-emoji {
|
||||
font-size: 52px;
|
||||
display: block;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
|
||||
animation: rpBounce 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rpBounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
.rp-sender {
|
||||
color: #fde68a;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rp-title {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.rp-amount {
|
||||
color: #fde68a;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-top: 6px;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.rp-amount small {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 红包底部区 */
|
||||
#rp-body {
|
||||
background: #fff8f0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.rp-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 倒计时条 */
|
||||
#rp-timer-bar-wrap {
|
||||
background: #fee2e2;
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#rp-timer-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #dc2626, #f97316);
|
||||
border-radius: 4px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
/* 领取按钮 */
|
||||
#rp-claim-btn {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: linear-gradient(135deg, #dc2626, #ea580c);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
|
||||
transition: opacity .2s, transform .15s;
|
||||
}
|
||||
|
||||
#rp-claim-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#rp-claim-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#rp-claim-btn:disabled {
|
||||
background: #9ca3af;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 已领取名单 */
|
||||
#rp-claims-list {
|
||||
margin-top: 12px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px dashed #fca5a5;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.rp-claim-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.rp-claim-item span:last-child {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
#rp-close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#rp-close-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 状态提示 */
|
||||
#rp-status-msg {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
min-height: 16px;
|
||||
color: #16a34a;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{-- 红包弹窗 DOM --}}
|
||||
<div id="red-packet-modal">
|
||||
<div id="red-packet-card">
|
||||
{{-- 顶部标题区 --}}
|
||||
<div id="rp-header">
|
||||
<span id="rp-close-btn" onclick="closeRedPacketModal()">✕</span>
|
||||
<span class="rp-emoji">🧧</span>
|
||||
<div class="rp-sender" id="rp-sender-name">xxx 的礼包</div>
|
||||
<div class="rp-title">聊天室专属礼包</div>
|
||||
<div class="rp-amount"><small>总计 </small><span id="rp-total-amount">888</span><small id="rp-type-label">
|
||||
金币</small></div>
|
||||
</div>
|
||||
{{-- 底部操作区 --}}
|
||||
<div id="rp-body">
|
||||
<div class="rp-info-row">
|
||||
<span>剩余份数:<b id="rp-remaining">10</b> / <span id="rp-total-count">10</span> 份</span>
|
||||
<span>倒计时:<b id="rp-countdown">120</b>s</span>
|
||||
</div>
|
||||
<div id="rp-timer-bar-wrap">
|
||||
<div id="rp-timer-bar" style="width:100%;"></div>
|
||||
</div>
|
||||
<button id="rp-claim-btn" onclick="claimRedPacket()">🧧 立即抢红包</button>
|
||||
<div id="rp-status-msg"></div>
|
||||
{{-- 领取名单 --}}
|
||||
<div id="rp-claims-list" style="display:none;">
|
||||
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
|
||||
<div id="rp-claims-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 礼包红包前端交互模块
|
||||
*
|
||||
* 功能:
|
||||
* 1. sendRedPacket() — superlevel 点击「礼包」按钮后确认发包
|
||||
* 2. showRedPacketModal() — 收到 RedPacketSent 事件后弹出红包卡片
|
||||
* 3. claimRedPacket() — 用户点击「立即抢红包」
|
||||
* 4. closeRedPacketModal() — 关闭红包弹窗
|
||||
* 5. WebSocket 监听 — 监听 red-packet.sent 广播事件
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 当前红包状态
|
||||
let _rpEnvelopeId = null; // 当前红包 ID
|
||||
let _rpExpireAt = null; // 过期时间戳(ms)
|
||||
let _rpTotalSeconds = 120; // 总倒计时秒数
|
||||
let _rpTimer = null; // 倒计时定时器
|
||||
let _rpClaimed = false; // 本次会话是否已领取
|
||||
let _rpType = 'gold'; // 当前红包类型(gold / exp)
|
||||
|
||||
// ── 发包确认 ───────────────────────────────────────
|
||||
/**
|
||||
* superlevel 点击「礼包」按钮,弹出 chatBanner 三按钮选择类型后发包。
|
||||
*/
|
||||
window.sendRedPacket = function() {
|
||||
window.chatBanner.show({
|
||||
icon: '🧧',
|
||||
title: '发出礼包',
|
||||
name: '选择礼包类型',
|
||||
body: '将发出 <b>888</b> 数量共 <b>10</b> 份的礼包,系统凭空发放,房间成员先到先得!',
|
||||
gradient: ['#991b1b', '#dc2626', '#ea580c'],
|
||||
titleColor: '#fde68a',
|
||||
autoClose: 0,
|
||||
buttons: [{
|
||||
label: '💰 金币礼包',
|
||||
color: '#d97706',
|
||||
onClick(btn, close) {
|
||||
close();
|
||||
doSendRedPacket('gold');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '✨ 经验礼包',
|
||||
color: '#7c3aed',
|
||||
onClick(btn, close) {
|
||||
close();
|
||||
doSendRedPacket('exp');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '取消',
|
||||
color: 'rgba(255,255,255,0.15)',
|
||||
onClick(btn, close) {
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 实际发包请求(由 chatBanner 按钮回调触发)。
|
||||
*
|
||||
* @param {'gold'|'exp'} type 货币类型
|
||||
*/
|
||||
async function doSendRedPacket(type) {
|
||||
const btn = document.getElementById('red-packet-btn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中…';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/command/red-packet/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_id: window.chatContext.roomId,
|
||||
type
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.status !== 'success') {
|
||||
await window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
|
||||
}
|
||||
// 成功后 WebSocket 广播 RedPacketSent,前端自动弹出红包卡片
|
||||
} catch (e) {
|
||||
await window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🧧 礼包';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示红包弹窗,并启动倒计时。
|
||||
*
|
||||
* @param {number} envelopeId 红包 ID
|
||||
* @param {string} senderUsername 发包人用户名
|
||||
* @param {number} totalAmount 总数量
|
||||
* @param {number} totalCount 总份数
|
||||
* @param {number} expireSeconds 有效秒数
|
||||
* @param {'gold'|'exp'} type 货币类型
|
||||
*/
|
||||
window.showRedPacketModal = function(envelopeId, senderUsername, totalAmount, totalCount, expireSeconds,
|
||||
type) {
|
||||
try {
|
||||
console.log('showRedPacketModal 触发,当前状态:', {
|
||||
envelopeId,
|
||||
senderUsername,
|
||||
totalAmount,
|
||||
totalCount,
|
||||
expireSeconds,
|
||||
type,
|
||||
oldId: _rpEnvelopeId
|
||||
});
|
||||
_rpEnvelopeId = envelopeId;
|
||||
_rpClaimed = false;
|
||||
_rpTotalSeconds = expireSeconds;
|
||||
_rpExpireAt = Date.now() + expireSeconds * 1000;
|
||||
_rpType = type || 'gold'; // 保存类型供 claimRedPacket 使用
|
||||
|
||||
// 根据类型调整配色和标签
|
||||
const isExp = (type === 'exp');
|
||||
const typeIcon = isExp ? '✨' : '💰';
|
||||
const typeName = isExp ? '经验' : '金币';
|
||||
const headerBg = isExp ?
|
||||
'linear-gradient(160deg,#6d28d9 0%,#5b21b6 50%,#4c1d95 100%)' :
|
||||
'linear-gradient(160deg,#dc2626 0%,#b91c1c 50%,#991b1b 100%)';
|
||||
const claimBg = isExp ?
|
||||
'linear-gradient(135deg,#7c3aed,#4f46e5)' :
|
||||
'linear-gradient(135deg,#dc2626,#ea580c)';
|
||||
|
||||
const modalEl = document.getElementById('red-packet-modal');
|
||||
if (!modalEl) {
|
||||
alert('致命错误:红包视图容器 #red-packet-modal 找不到!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 强制解除隐藏,赋予超高权限层级
|
||||
modalEl.style.setProperty('display', 'flex', 'important');
|
||||
modalEl.style.setProperty('z-index', '9999999', 'important');
|
||||
modalEl.style.setProperty('opacity', '1', 'important');
|
||||
modalEl.style.setProperty('visibility', 'visible', 'important');
|
||||
|
||||
// 应用配色
|
||||
document.getElementById('rp-header').style.background = headerBg;
|
||||
const claimBtn = document.getElementById('rp-claim-btn');
|
||||
if (claimBtn) {
|
||||
claimBtn.style.background = claimBg;
|
||||
}
|
||||
|
||||
// 填入数据
|
||||
document.getElementById('rp-sender-name').textContent = senderUsername + ' 的礼包';
|
||||
document.getElementById('rp-total-amount').textContent = totalAmount;
|
||||
document.getElementById('rp-total-count').textContent = totalCount;
|
||||
document.getElementById('rp-remaining').textContent = totalCount;
|
||||
document.getElementById('rp-countdown').textContent = expireSeconds;
|
||||
document.getElementById('rp-timer-bar').style.width = '100%';
|
||||
document.getElementById('rp-status-msg').textContent = '';
|
||||
document.getElementById('rp-claims-list').style.display = 'none';
|
||||
document.getElementById('rp-claims-items').innerHTML = '';
|
||||
|
||||
// 更新卡片标题信息
|
||||
const emojiEl = modalEl.querySelector('.rp-emoji');
|
||||
if (emojiEl) emojiEl.textContent = typeIcon;
|
||||
|
||||
const titleEl = modalEl.querySelector('.rp-title');
|
||||
if (titleEl) titleEl.textContent = typeName + '礼包';
|
||||
|
||||
const typeLabel = document.getElementById('rp-type-label');
|
||||
if (typeLabel) typeLabel.textContent = ' ' + typeName;
|
||||
|
||||
if (claimBtn) {
|
||||
claimBtn.disabled = false;
|
||||
claimBtn.textContent = typeIcon + ' 立即抢包';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('showRedPacketModal 执行失败:', err);
|
||||
alert('红包弹窗初始化异常: ' + err.message);
|
||||
}
|
||||
|
||||
// 启动倒计时
|
||||
clearInterval(_rpTimer);
|
||||
_rpTimer = setInterval(() => {
|
||||
const remaining = Math.max(0, Math.ceil((_rpExpireAt - Date.now()) / 1000));
|
||||
document.getElementById('rp-countdown').textContent = remaining;
|
||||
document.getElementById('rp-timer-bar').style.width =
|
||||
(remaining / _rpTotalSeconds * 100) + '%';
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(_rpTimer);
|
||||
document.getElementById('rp-claim-btn').disabled = true;
|
||||
document.getElementById('rp-claim-btn').textContent = '礼包已过期';
|
||||
document.getElementById('rp-status-msg').style.color = '#9ca3af';
|
||||
document.getElementById('rp-status-msg').textContent = '红包已过期。';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 异步拉取服务端最新状态(实时刷新剩余份数)
|
||||
fetch(`/red-packet/${envelopeId}/status`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新剩余份数显示
|
||||
document.getElementById('rp-remaining').textContent = data.remaining_count;
|
||||
|
||||
// 若已过期 → 关闭弹窗 + Toast 提示
|
||||
if (data.is_expired || data.envelope_status === 'expired') {
|
||||
clearInterval(_rpTimer);
|
||||
closeRedPacketModal();
|
||||
window.chatToast?.show({
|
||||
title: '⏰ 礼包已过期',
|
||||
message: '该礼包已超过有效期,无法领取。',
|
||||
icon: '⏰',
|
||||
color: '#9ca3af',
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 若已抢完 → 关闭弹窗 + Toast 提示
|
||||
if (data.remaining_count <= 0 || data.envelope_status === 'completed') {
|
||||
clearInterval(_rpTimer);
|
||||
closeRedPacketModal();
|
||||
window.chatToast?.show({
|
||||
title: '😅 手慢了!',
|
||||
message: '礼包已被抢完,下次要快一点哦!',
|
||||
icon: '🧧',
|
||||
color: '#f59e0b',
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 若本人已领取 → 关闭弹窗 + Toast 提示
|
||||
if (data.has_claimed) {
|
||||
clearInterval(_rpTimer);
|
||||
closeRedPacketModal();
|
||||
window.chatToast?.show({
|
||||
title: '✅ 已领取',
|
||||
message: '您已成功领取过本次礼包!',
|
||||
icon: '🧧',
|
||||
color: '#10b981',
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // 静默忽略网络错误,不影响弹窗展示
|
||||
};
|
||||
|
||||
// ── 抢包/关闭逻辑 ─────────────────────────────────────
|
||||
window.closeRedPacketModal = function() {
|
||||
console.trace('closeRedPacketModal 被调用');
|
||||
document.getElementById('red-packet-modal').style.display = 'none';
|
||||
if (_rpTimer) clearInterval(_rpTimer);
|
||||
};
|
||||
|
||||
// 点击遮罩关闭
|
||||
document.getElementById('red-packet-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeRedPacketModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 抢红包 ──────────────────────────────────────
|
||||
/**
|
||||
* 用户点击「立即抢红包」,调用后端 claim 接口。
|
||||
*/
|
||||
window.claimRedPacket = async function() {
|
||||
if (!_rpEnvelopeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('rp-claim-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '抢包中…';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/red-packet/${_rpEnvelopeId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_id: window.chatContext.roomId
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const statusEl = document.getElementById('rp-status-msg');
|
||||
const typeLabel = (_rpType === 'exp') ? '经验' : '金币';
|
||||
if (res.ok && data.status === 'success') {
|
||||
_rpClaimed = true;
|
||||
btn.textContent = '🎉 已抢到!';
|
||||
statusEl.style.color = '#16a34a';
|
||||
statusEl.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}!`;
|
||||
|
||||
// 弹出全局 Toast
|
||||
window.chatToast.show({
|
||||
title: '🧧 礼包到账',
|
||||
message: `恭喜您抢到了礼包 <b>${data.amount}</b> ${typeLabel}!`,
|
||||
icon: '🧧',
|
||||
color: (_rpType === 'exp') ? '#7c3aed' : '#dc2626',
|
||||
duration: 8000,
|
||||
});
|
||||
|
||||
// 3 秒后自动关闭弹窗
|
||||
setTimeout(() => closeRedPacketModal(), 3000);
|
||||
} else {
|
||||
statusEl.style.color = '#dc2626';
|
||||
statusEl.textContent = data.message || '抢包失败';
|
||||
// 若已领过或已抢完则禁用按钮,否则解除禁用以重试
|
||||
if (data.message && (data.message.includes('已经领过') || data.message.includes('已被抢完') ||
|
||||
data.message.includes('已抢完'))) {
|
||||
btn.textContent = '已参与';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🧧 立即抢红包';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('rp-status-msg').textContent = '网络异常,请重试';
|
||||
document.getElementById('rp-status-msg').style.color = '#dc2626';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🧧 立即抢红包';
|
||||
}
|
||||
};
|
||||
|
||||
// ── 更新领取名单(被 WS 触发调用)───────────────
|
||||
/**
|
||||
* 收到「系统传音」中的领取播报时,同步更新弹窗内的名单与剩余数。
|
||||
*
|
||||
* @param {string} username 领取者用户名
|
||||
* @param {number} amount 领取金额
|
||||
* @param {number} remaining 剩余份数
|
||||
*/
|
||||
window.updateRedPacketClaimsUI = function(username, amount, remaining) {
|
||||
const remainingEl = document.getElementById('rp-remaining');
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = remaining;
|
||||
}
|
||||
|
||||
const listEl = document.getElementById('rp-claims-list');
|
||||
const itemsEl = document.getElementById('rp-claims-items');
|
||||
if (!listEl || !itemsEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.style.display = 'block';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'rp-claim-item';
|
||||
item.innerHTML = `<span>${username}</span><span>+${amount} 金币</span>`;
|
||||
itemsEl.prepend(item);
|
||||
|
||||
// 若已全部领完,更新按钮状态
|
||||
if (remaining <= 0) {
|
||||
const btn = document.getElementById('rp-claim-btn');
|
||||
if (btn && !_rpClaimed) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '礼包已被抢完!';
|
||||
}
|
||||
clearInterval(_rpTimer);
|
||||
// 3 秒后自动关闭
|
||||
setTimeout(() => closeRedPacketModal(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// ── WebSocket 监听 red-packet.sent ───────────────
|
||||
/**
|
||||
* 等待 Echo 就绪后注册 red-packet.sent 事件监听,
|
||||
* 每次收到新红包时弹出红包卡片弹窗。
|
||||
*/
|
||||
function setupRedPacketListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
setTimeout(setupRedPacketListener, 500);
|
||||
return;
|
||||
}
|
||||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||||
.listen('.red-packet.sent', (e) => {
|
||||
// 收到红包事件,弹出卡片(type 决定金币/经验配色)
|
||||
showRedPacketModal(
|
||||
e.envelope_id,
|
||||
e.sender_username,
|
||||
e.total_amount,
|
||||
e.total_count,
|
||||
e.expire_seconds,
|
||||
e.type || 'gold',
|
||||
);
|
||||
});
|
||||
console.log('RedPacketSent 监听器已注册');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', setupRedPacketListener);
|
||||
|
||||
})(); // end IIFE
|
||||
</script>
|
||||
@@ -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">×</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>
|
||||
Reference in New Issue
Block a user