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:神秘箱子移入已完成区,补全修复记录
This commit is contained in:
2026-03-03 19:29:43 +08:00
parent 40fcce2db3
commit 602dcd7cf1
21 changed files with 1799 additions and 139 deletions
@@ -100,6 +100,31 @@
<span class="text-xs text-gray-400">修改后立即生效(缓存60秒刷新)</span>
</div>
</form>
{{-- 神秘箱子:手动投放区域 --}}
@if ($game->game_key === 'mystery_box')
<div class="mt-4 pt-4 border-t border-gray-100">
<div class="text-xs font-bold text-gray-600 mb-2">🎯 手动投放箱子</div>
<div class="flex items-center gap-3 flex-wrap">
<button onclick="dropBox('normal', {{ $game->id }})"
style="padding:8px 16px; background:linear-gradient(135deg,#059669,#10b981); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;"
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
📦 投放普通箱
</button>
<button onclick="dropBox('rare', {{ $game->id }})"
style="padding:8px 16px; background:linear-gradient(135deg,#7c3aed,#a78bfa); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;"
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
💎 投放稀有箱
</button>
<button onclick="dropBox('trap', {{ $game->id }})"
style="padding:8px 16px; background:linear-gradient(135deg,#7f1d1d,#ef4444); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;"
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
☠️ 投放黑化箱
</button>
<span class="text-xs text-gray-400">直接向 #1 房间投放,立即广播暗号</span>
</div>
</div>
@endif
</div>
</div>
@endforeach
@@ -153,10 +178,58 @@
header.classList.toggle('bg-gray-50', !enabled);
}
// Toast 提示
alert(data.message);
// 全局弹窗提示
window.adminDialog.alert(data.message, enabled ? '游戏已开启' : '游戏已关闭', enabled ? '✅' : '⏸');
});
}
/**
* 管理员手动投放神秘箱子
*
* @param {string} boxType 箱子类型:normal | rare | trap
*/
function dropBox(boxType) {
const typeNames = {
normal: '普通箱',
rare: '稀有箱',
trap: '黑化箱'
};
const typeIcons = {
normal: '📦',
rare: '💎',
trap: '☠️'
};
const name = typeNames[boxType] || boxType;
const icon = typeIcons[boxType] || '📦';
window.adminDialog.confirm(
`确定要向 <b>#1 房间</b> 投放一个「${name}」吗?<br><span style="color:#64748b; font-size:12px;">箱子投放后将立即在公屏广播暗号,用户限时领取。</span>`,
`投放${name}`,
() => {
fetch('/admin/mystery-box/drop', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
box_type: boxType
}),
})
.then(r => r.json())
.then(data => {
window.adminDialog.alert(
data.message || (data.ok ? '投放成功!' : '投放失败'),
data.ok ? '投放成功' : '投放失败',
data.ok ? icon : '❌'
);
})
.catch(() => window.adminDialog.alert('网络错误,请重试', '网络错误', '🌐'));
},
icon
);
}
</script>
@endsection
@@ -200,6 +273,14 @@
'auto_drop_enabled' => ['label' => '自动定时投放', 'type' => 'boolean', 'unit' => ''],
'auto_interval_hours' => ['label' => '自动投放间隔', 'type' => 'number', 'unit' => '小时', 'min' => 1],
'claim_window_seconds' => ['label' => '领取窗口', 'type' => 'number', 'unit' => '秒', 'min' => 10],
// 新键名
'normal_reward_min' => ['label' => '普通箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'normal_reward_max' => ['label' => '普通箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'rare_reward_min' => ['label' => '稀有箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'rare_reward_max' => ['label' => '稀有箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'trap_penalty_min' => ['label' => '黑化箱最低惩罚', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'trap_penalty_max' => ['label' => '黑化箱最高惩罚', 'type' => 'number', 'unit' => '金币', 'min' => 1],
// 旧键名兼容(数据库中已存在的旧配置)
'min_reward' => ['label' => '普通箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'max_reward' => ['label' => '普通箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'rare_min_reward' => ['label' => '稀有箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
+130
View File
@@ -175,6 +175,136 @@
@yield('content')
</div>
</main>
{{-- ══════════════════════════════════════════════════════════
全局弹窗组件:window.adminDialog.alert / window.adminDialog.confirm
用法:
window.adminDialog.alert('操作成功!', '✅ 提示');
window.adminDialog.confirm('确定要删除?', '⚠️ 确认', () => { ... });
══════════════════════════════════════════════════════════ --}}
<div id="admin-dialog-overlay"
style="display:none; position:fixed; inset:0; background:rgba(15,23,42,.55);
backdrop-filter:blur(3px); z-index:99999; align-items:center; justify-content:center;">
<div id="admin-dialog-box"
style="background:#fff; border-radius:16px; box-shadow:0 24px 64px rgba(0,0,0,.22);
min-width:320px; max-width:480px; width:90%; padding:32px 32px 24px; text-align:center;
animation:admin-dialog-pop .25s cubic-bezier(.175,.885,.32,1.275);">
<div id="admin-dialog-icon" style="font-size:36px; margin-bottom:10px;"></div>
<div id="admin-dialog-title" style="font-size:16px; font-weight:800; color:#1e293b; margin-bottom:8px;">
</div>
<div id="admin-dialog-msg" style="font-size:14px; color:#475569; line-height:1.6; margin-bottom:20px;">
</div>
<div id="admin-dialog-btns" style="display:flex; gap:10px; justify-content:center;"></div>
</div>
</div>
<style>
@keyframes admin-dialog-pop {
0% {
opacity: 0;
transform: scale(.8);
}
70% {
transform: scale(1.03);
}
100% {
opacity: 1;
transform: scale(1);
}
}
</style>
<script>
/**
* 后台全局弹窗组件。
*
* 提供 alert / confirm 两种模式,替换原生 alert/confirm。
*/
window.adminDialog = (function() {
const overlay = document.getElementById('admin-dialog-overlay');
const box = document.getElementById('admin-dialog-box');
const elIcon = document.getElementById('admin-dialog-icon');
const elTitle = document.getElementById('admin-dialog-title');
const elMsg = document.getElementById('admin-dialog-msg');
const elBtns = document.getElementById('admin-dialog-btns');
/** 关闭弹窗 */
function close() {
overlay.style.display = 'none';
}
/** 点击遮罩层关闭 */
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close();
});
/**
* 创建按钮元素
*
* @param {string} label 按钮文字
* @param {string} color 按钮背景色
* @param {Function} onClick 点击回调
*/
function makeBtn(label, color, onClick) {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = `padding:9px 24px; border-radius:8px; border:none; cursor:pointer;
font-size:14px; font-weight:700; color:#fff; background:${color};
transition:opacity .15s; box-shadow:0 3px 10px rgba(0,0,0,.12);`;
btn.onmouseover = () => btn.style.opacity = '.82';
btn.onmouseout = () => btn.style.opacity = '1';
btn.addEventListener('click', () => {
close();
if (onClick) onClick();
});
return btn;
}
/**
* 弹出提示框(仅「确定」按钮)
*
* @param {string} message 消息内容(支持 HTML
* @param {string} title 标题
* @param {string} icon 图标 Emoji
* @param {Function} onOk 确定回调
*/
function alert(message, title = '提示', icon = '️', onOk = null) {
elIcon.textContent = icon;
elTitle.textContent = title;
elMsg.innerHTML = message;
elBtns.innerHTML = '';
elBtns.appendChild(makeBtn('确定', '#4f46e5', onOk));
overlay.style.display = 'flex';
}
/**
* 弹出确认框(「确定」+「取消」按钮)
*
* @param {string} message 消息内容
* @param {string} title 标题
* @param {Function} onConfirm 确认回调
* @param {string} icon 图标 Emoji
*/
function confirm(message, title = '确认操作', onConfirm = null, icon = '⚠️') {
elIcon.textContent = icon;
elTitle.textContent = title;
elMsg.innerHTML = message;
elBtns.innerHTML = '';
const confirmBtn = makeBtn('确定', '#4f46e5', onConfirm);
const cancelBtn = makeBtn('取消', '#94a3b8', null);
elBtns.appendChild(confirmBtn);
elBtns.appendChild(cancelBtn);
overlay.style.display = 'flex';
}
return {
alert,
confirm,
close
};
})();
</script>
</body>
</html>