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>
+3 -1
View File
@@ -84,7 +84,7 @@
};
</script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<link rel="stylesheet" href="/css/chat.css">
</head>
@@ -141,6 +141,8 @@
@include('chat.partials.baccarat-panel')
{{-- ═══════════ 老虎机游戏面板 ═══════════ --}}
@include('chat.partials.slot-machine')
{{-- ═══════════ 神秘箱子游戏面板 ═══════════ --}}
@include('chat.partials.mystery-box')
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
<script src="/js/effects/effect-sounds.js"></script>
@@ -295,130 +295,78 @@
</div>
</div>
{{-- ─── 悬浮入口(游戏开启时常驻,支持拖拽) ─── --}}
<div id="baccarat-fab" x-data="{ visible: false }" x-show="visible" x-cloak
style="position:fixed; bottom:90px; right:18px; z-index:9900; touch-action:none;">
<button id="baccarat-fab-btn" x-on:click.stop="() => {}"
style="width:52px; height:52px; border-radius:50%; border:none; cursor:grab;
{{-- ─── 骰悬浮入口(游戏开启时常驻,支持拖拽) ─── --}}
<div id="baccarat-fab" x-data="baccaratFab()" x-show="visible" x-cloak
:style="'position:fixed; right:' + posX + 'px; bottom:' + posY + 'px; z-index:9900; 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:52px; height:52px; border-radius:50%; border:none;
background:linear-gradient(135deg,#7c3aed,#4f46e5);
box-shadow:0 4px 20px rgba(124,58,237,.5);
font-size:22px; display:flex; align-items:center; justify-content:center;
animation:pulse-fab 2s infinite; user-select:none;"
title="百家乐下注中(可拖动)">🎲</button>
:style="dragging ? 'cursor:grabbing;' : 'cursor:grab;'" title="百家乐下注中(可拖动)">🎲</button>
</div>
<script>
/**
* 百家乐骰悬浮按钮拖拽逻辑
* 需在 DOM 就绪后初始化,因为 x-cloak 元素初始隐藏
* 百家乐骰悬浮按钮 Alpine 组件(拖动 + localStorage 位置持久化)
*/
(function initBaccaratFabDrag() {
const LS_KEY = 'baccaratFabPos';
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,
function attachDrag() {
const fab = document.getElementById('baccarat-fab');
const btn = document.getElementById('baccarat-fab-btn');
if (!fab || !btn || fab._dragInited) return;
fab._dragInited = true;
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);
},
// 恢复上次拖拽位置
const saved = (() => {
try {
return JSON.parse(localStorage.getItem(LS_KEY));
} catch {
return null;
}
})();
if (saved) {
fab.style.left = saved.left + 'px';
fab.style.top = saved.top + 'px';
fab.style.right = 'auto';
fab.style.bottom = 'auto';
}
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));
},
let isDragging = false;
let startX, startY, startLeft, startTop;
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();
},
function onStart(e) {
const rect = fab.getBoundingClientRect();
// 转为绝对 left/top
fab.style.left = rect.left + 'px';
fab.style.top = rect.top + 'px';
fab.style.right = 'auto';
fab.style.bottom = 'auto';
isDragging = false;
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
startX = cx;
startY = cy;
startLeft = rect.left;
startTop = rect.top;
btn.style.cursor = 'grabbing';
document.addEventListener('mousemove', onMove, {
passive: false
});
document.addEventListener('mouseup', onEnd);
document.addEventListener('touchmove', onMove, {
passive: false
});
document.addEventListener('touchend', onEnd);
}
function onMove(e) {
e.preventDefault();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
const dx = cx - startX,
dy = cy - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) isDragging = true;
if (!isDragging) return;
const newLeft = Math.max(0, Math.min(window.innerWidth - fab.offsetWidth, startLeft + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - fab.offsetHeight, startTop + dy));
fab.style.left = newLeft + 'px';
fab.style.top = newTop + 'px';
}
function onEnd() {
btn.style.cursor = 'grab';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onEnd);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
if (isDragging) {
localStorage.setItem(LS_KEY, JSON.stringify({
left: parseInt(fab.style.left),
top: parseInt(fab.style.top),
}));
}
}
btn.addEventListener('mousedown', onStart);
btn.addEventListener('touchstart', onStart, {
passive: true
});
// 非拖拽时才打开面板
btn.addEventListener('click', () => {
if (isDragging) return;
const p = Alpine.$data(document.getElementById('baccarat-panel'));
if (!p) return;
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();
}
});
}
// 首次尝试(元素可能已在 DOM 中)
document.addEventListener('DOMContentLoaded', attachDrag);
// Alpine 使 x-cloak 元素出现后再次尝试
document.addEventListener('alpine:initialized', () => setTimeout(attachDrag, 100));
})();
},
};
}
</script>
<style>
@@ -634,7 +582,7 @@
*/
updateFab(visible) {
const fab = document.getElementById('baccarat-fab');
if (fab) fab._x_dataStack[0].visible = visible;
if (fab) Alpine.$data(fab).visible = visible;
},
/**
@@ -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>
@@ -1262,6 +1262,59 @@
return;
}
// ── 神秘箱子暗号拦截 ────────────────────────────────────
// 当用户输入内容符合暗号格式(4-8位大写字母/数字)时,主动尝试领取
// 不依赖轮询标志,服务端负责校验是否真有活跃箱子及暗号是否匹配
const passcodePattern = /^[A-Z0-9]{4,8}$/;
if (passcodePattern.test(content.trim())) {
_isSending = false;
try {
const claimRes = await fetch('/mystery-box/claim', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
passcode: content.trim()
}),
});
const claimData = await claimRes.json();
if (claimData.ok) {
// ✅ 领取成功:清空输入框,不发送普通消息
contentInput.value = '';
contentInput.focus();
// 清除活跃箱子全局标志
window._mysteryBoxActive = false;
window._mysteryBoxPasscode = null;
// 弹出开箱结果卡片
const isPositive = (claimData.reward ?? 1) >= 0;
window.chatDialog?.alert(
claimData.message || '开箱成功!',
isPositive ? '🎉 恭喜!' : '☠️ 中了陷阱!',
isPositive ? '#10b981' : '#ef4444',
);
// 更新全局金币余额显示
if (window.__chatUser && claimData.balance !== undefined) {
window.__chatUser.jjb = claimData.balance;
}
return;
}
// ❌ 领取失败(暗号错误 / 无活跃箱子 / 已被领走)
// 静默回退到正常发送——不弹错误提示,让消息正常发出
} catch (_) {
// 网络错误时同样静默回退正常发送
}
}
submitBtn.disabled = true;
try {
+278 -1
View File
@@ -19,7 +19,7 @@
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
<div class="tool-btn" onclick="alert('🚧 娱乐功能开发中,敬请期待!')" title="娱乐(待开发)">娱乐</div>
<div class="tool-btn" onclick="alert('🚧 银行功能开发中,敬请期待!')" title="银行(待开发)">银行</div>
<div class="tool-btn" onclick="alert('🚧 呼叫功能开发中,敬请期待!')" title="呼叫(待开发)">呼叫</div>
<div class="tool-btn" onclick="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
<div class="tool-btn" onclick="openAvatarPicker()" title="修改头像">头像</div>
<div class="tool-btn" onclick="document.getElementById('settings-modal').style.display='flex'" title="个人设置">设置
@@ -1051,3 +1051,280 @@
}
})();
</script>
{{-- ═══════════ 婚姻状态弹窗 ═══════════ --}}
<div id="marriage-status-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.5);
z-index:9999; justify-content:center; align-items:center;">
<div
style="background:#fff; border-radius:10px; width:360px; max-width:94vw;
box-shadow:0 12px 40px rgba(0,0,0,.3); overflow:hidden;
animation:gdSlideIn .18s ease; display:flex; flex-direction:column;">
{{-- 标题栏 --}}
<div
style="background:linear-gradient(135deg,#be185d,#f43f5e,#ec4899);
color:#fff; padding:12px 16px;
display:flex; align-items:center; justify-content:space-between;">
<span style="font-size:14px; font-weight:bold;">💍 我的婚姻</span>
<span onclick="closeMarriageStatusModal()"
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1;"></span>
</div>
{{-- 内容区(动态渲染) --}}
<div id="marriage-status-body" style="padding:16px; min-height:120px;">
<div style="text-align:center; color:#aaa; padding:30px 0; font-size:12px;">加载中…</div>
</div>
{{-- 底部操作区 --}}
<div id="marriage-status-footer" style="padding:0 16px 16px; display:flex; gap:8px;"></div>
</div>
</div>
<script>
/**
* 婚姻状态弹窗——工具栏点击「婚姻」按钮触发。
* 调用 /marriage/status 接口,展示当前用户婚姻状态(单身/求婚中/已婚)。
*/
(function() {
const CSRF = () => document.querySelector('meta[name="csrf-token"]')?.content ?? '';
const $ = id => document.getElementById(id);
/** 打开弹窗并拉取状态 */
window.openMarriageStatusModal = function() {
$('marriage-status-modal').style.display = 'flex';
$('marriage-status-body').innerHTML =
'<div style="text-align:center;color:#aaa;padding:30px 0;font-size:12px;">加载中…</div>';
$('marriage-status-footer').innerHTML = '';
fetch('/marriage/status', {
headers: {
Accept: 'application/json'
}
})
.then(r => r.json())
.then(renderMarriageStatus)
.catch(() => {
$('marriage-status-body').innerHTML =
'<div style="text-align:center;color:#e55;padding:30px 0;font-size:12px;">❌ 加载失败,请稍后重试</div>';
});
};
/** 关闭弹窗 */
window.closeMarriageStatusModal = function() {
$('marriage-status-modal').style.display = 'none';
};
// 点击遮罩关闭
$('marriage-status-modal').addEventListener('click', function(e) {
if (e.target === this) closeMarriageStatusModal();
});
/**
* 根据接口返回数据渲染弹窗内容。
*
* @param {object} data `/marriage/status` 响应 JSON
*/
function renderMarriageStatus(data) {
const body = $('marriage-status-body');
const footer = $('marriage-status-footer');
// ── 单身 ────────────────────────────────────
if (!data.status || data.status === 'none' || !data.marriage) {
body.innerHTML = `
<div style="text-align:center; padding:16px 0;">
<div style="font-size:40px; margin-bottom:10px;">🕊️</div>
<div style="font-size:14px; font-weight:bold; color:#555;">目前单身</div>
<div style="font-size:11px; color:#999; margin-top:6px; line-height:1.7;">
还没有婚姻记录。<br>可在用户名片上点击「求婚」发起求婚。
</div>
</div>`;
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1; padding:9px; background:#f3f4f6; color:#555;
border:1px solid #d1d5db; border-radius:6px; font-size:13px; cursor:pointer;">
关闭
</button>`;
return;
}
const m = data.marriage;
const isMine = (m.user && m.user.username === window.__chatUser?.username) ||
window.__chatUser?.id === m.user?.id ||
window.__chatUser?.id === m.partner?.id;
// 确定"另一方"信息(我可能是 user 也可能是 partner
const me = window.__chatUser;
const other = (m.user?.id === me?.id) ? m.partner : m.user;
const iAmUser = (m.user?.id === me?.id);
// ── 求婚中 ──────────────────────────────────
if (data.status === 'pending') {
const iProposed = iAmUser; // user_id 是发起方
const expireAt = m.expires_at ? new Date(m.expires_at).toLocaleString('zh-CN', {
hour12: false
}) : '—';
const ringHtml = m.ring ?
`<span style="font-size:13px;">${m.ring.icon ?? '💍'} ${m.ring.name}</span>` : '';
body.innerHTML = `
<div style="text-align:center; padding:8px 0;">
<div style="font-size:36px; margin-bottom:8px;">💌</div>
<div style="font-size:14px; font-weight:bold; color:#be185d;">
${iProposed ? '你向 ' + (other?.username ?? '—') + ' 发出了求婚' : (other?.username ?? '—') + ' 向你求婚啦!'}
</div>
${ringHtml ? `<div style="margin:8px 0; font-size:12px; color:#666;">戒指:${ringHtml}</div>` : ''}
<div style="font-size:11px; color:#999; margin-top:6px;">
过期时间:${expireAt}
</div>
</div>`;
if (!iProposed) {
// 被求婚方:可以接受 / 拒绝
footer.innerHTML = `
<button onclick="marriageAction('${m.id}','reject'); closeMarriageStatusModal();"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
😢 婉拒
</button>
<button onclick="marriageAction('${m.id}','accept'); closeMarriageStatusModal();"
style="flex:1;padding:9px;background:linear-gradient(135deg,#be185d,#f43f5e);
color:#fff;border:none;border-radius:6px;font-size:13px;
font-weight:bold;cursor:pointer;">
💑 答应啦!
</button>`;
} else {
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
关闭(等待对方回应)
</button>`;
}
return;
}
// ── 已婚 ────────────────────────────────────
if (data.status === 'married') {
const levelIcon = m.level_icon ?? '💑';
const levelName = m.level_name ?? '新婚';
const days = m.days ?? 0;
const intimacy = m.intimacy ?? 0;
const marriedAt = m.married_at ?? '—';
const ringHtml = m.ring ? `${m.ring.icon ?? '💍'} ${m.ring.name}` : '无';
body.innerHTML = `
<div style="text-align:center; margin-bottom:12px;">
<div style="font-size:36px; margin-bottom:6px;">${levelIcon}</div>
<div style="font-size:14px; font-weight:bold; color:#be185d;">
已与 <strong>${other?.username ?? '—'}</strong> 成婚 🎉
</div>
<div style="font-size:12px; color:#999; margin-top:4px;">婚姻等级:${levelName}</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; font-size:12px;">
<div style="background:#fdf2f8;border:1px solid #fbcfe8;border-radius:6px;padding:10px;text-align:center;">
<div style="color:#be185d;font-weight:bold;font-size:18px;">${days}</div>
<div style="color:#888;margin-top:2px;">携手天数</div>
</div>
<div style="background:#fdf4ff;border:1px solid #e9d5ff;border-radius:6px;padding:10px;text-align:center;">
<div style="color:#7c3aed;font-weight:bold;font-size:18px;">${Number(intimacy).toLocaleString()}</div>
<div style="color:#888;margin-top:2px;">亲密度</div>
</div>
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;padding:8px 10px;grid-column:1/-1;">
<span style="color:#666;">💍 戒指:</span><span style="color:#333;">${ringHtml}</span>
&nbsp;&nbsp;
<span style="color:#666;">📅 婚期:</span><span style="color:#333;">${marriedAt}</span>
</div>
</div>`;
// 已婚底部:离婚入口(需要二次确认)
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
关闭
</button>
<button onclick="tryDivorce('${m.id}')"
style="flex:.8;padding:9px;border:1px solid #fca5a5;background:#fff;
color:#dc2626;border-radius:6px;font-size:12px;cursor:pointer;">
💔 申请离婚
</button>`;
return;
}
// 其他状态(divorced 等)
body.innerHTML =
`<div style="text-align:center;color:#999;padding:30px 0;font-size:12px;">暂无有效婚姻记录</div>`;
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">关闭</button>`;
}
/**
* 通用婚姻操作(接受 / 拒绝求婚)
*
* @param {string|number} marriageId marriage 记录 ID
* @param {string} action 'accept' | 'reject'
*/
window.marriageAction = async function(marriageId, action) {
try {
const res = await fetch(`/marriage/${marriageId}/${action}`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': CSRF(),
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
const data = await res.json();
if (data.ok) {
window.chatDialog?.alert(data.message || (action === 'accept' ? '已接受求婚!' : '已婉拒求婚'),
action === 'accept' ? '💑 恭喜!' : '提示', action === 'accept' ? '#be185d' :
'#6b7280');
} else {
window.chatDialog?.alert(data.message || '操作失败', '提示', '#f59e0b');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
}
};
/**
* 申请离婚(先弹确认框,再调接口)
*
* @param {string|number} marriageId marriage 记录 ID
*/
window.tryDivorce = async function(marriageId) {
closeMarriageStatusModal();
const confirmed = await window.chatDialog?.confirm(
'申请协议离婚后,对方有权同意或拒绝(拒绝即转为强制离婚,双方均扣除魅力值)。\n\n确定要申请吗?',
'💔 申请离婚',
'#dc2626',
);
if (!confirmed) {
return;
}
try {
const res = await fetch(`/marriage/${marriageId}/divorce`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': CSRF(),
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
type: 'mutual'
}),
});
const data = await res.json();
window.chatDialog?.alert(data.message || '申请已发送', '提示', data.ok ? '#10b981' : '#f59e0b');
} catch {
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
}
};
})();
</script>