Files
chatroom/resources/views/chat/partials/mystery-box.blade.php
lkddi 602dcd7cf1 feat: 神秘箱子系统完整实现 + 婚姻状态弹窗 + 工具栏优化
## 新功能
- 神秘箱子系统(MysteryBox)完整实现:
  - 新增 MysteryBox / MysteryBoxClaim 模型及迁移文件
  - DropMysteryBoxJob / ExpireMysteryBoxJob 队列作业
  - MysteryBoxController(/mystery-box/status + /mystery-box/claim)
  - 支持三种类型:普通箱(500~2000金)/ 稀有箱(5000~20000金)/ 黑化箱(陷阱扣200~1000金)
  - 调度器自动投放 + 管理员手动投放
  - CurrencySource 新增 MYSTERY_BOX / MYSTERY_BOX_TRAP 枚举

- 婚姻状态弹窗(工具栏「婚姻」按钮):
  - 工具栏「呼叫」改为「婚姻」,点击打开婚姻状态弹窗
  - 动态渲染三种状态:单身 / 求婚中 / 已婚
  - 被求婚方可直接「答应 / 婉拒」;已婚可申请离婚(含二次确认)

## 优化修复
- frame.blade.php:Alpine.js CDN 补加 defer,修复所有组件初始化报错
- scripts.blade.php:神秘箱子暗号主动拦截(不依赖轮询),领取成功后弹 chatDialog 展示结果,更新金币余额
- MysteryBoxController:claim() 时 change() 补传 room_id 记录来源房间
- 后台游戏管理页(game-configs):投放箱子按钮颜色修复;弹窗替换为 window.adminDialog
- admin/layouts:新增全局 adminDialog 弹窗组件(替代原生 alert/confirm)
- baccarat-panel:FAB 拖动重构为 Alpine.js baccaratFab() 组件,与 slotFab 一致
- GAMES_TODO.md:神秘箱子移入已完成区,补全修复记录
2026-03-03 19:29:43 +08:00

423 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能神秘箱子游戏前台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>