Files
chatroom/resources/views/admin/game-configs/index.blade.php
T
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

342 lines
20 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.
@extends('admin.layouts.app')
@section('title', '游戏管理')
@section('content')
<div class="space-y-6">
{{-- 页头 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 class="text-lg font-bold text-gray-800">🎮 游戏管理</h2>
<p class="text-xs text-gray-500 mt-1">统一管理聊天室所有娱乐游戏的开关状态与核心参数,所有游戏默认关闭。</p>
</div>
@if (session('success'))
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg text-sm">
{{ session('success') }}
</div>
@endif
{{-- 游戏卡片列表 --}}
<div class="grid grid-cols-1 gap-6">
@foreach ($games as $game)
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
id="game-card-{{ $game->game_key }}">
{{-- 卡片头部:游戏名 + 开关 --}}
<div
class="flex items-center justify-between p-5 border-b border-gray-100
{{ $game->enabled ? 'bg-emerald-50' : 'bg-gray-50' }}">
<div class="flex items-center gap-3">
<span class="text-3xl">{{ $game->icon }}</span>
<div>
<div class="font-bold text-gray-800 flex items-center gap-2">
{{ $game->name }}
<span id="badge-{{ $game->game_key }}"
class="text-xs px-2 py-0.5 rounded-full font-bold
{{ $game->enabled ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-500' }}">
{{ $game->enabled ? '运行中' : '已关闭' }}
</span>
</div>
<div class="text-xs text-gray-500 mt-0.5">{{ $game->description }}</div>
</div>
</div>
{{-- 大开关按钮 --}}
<button onclick="toggleGame('{{ $game->game_key }}', {{ $game->id }})"
id="toggle-btn-{{ $game->game_key }}"
class="px-5 py-2 rounded-lg font-bold text-sm transition shadow-sm
{{ $game->enabled ? 'bg-red-500 hover:bg-red-600 text-white' : 'bg-emerald-500 hover:bg-emerald-600 text-white' }}">
{{ $game->enabled ? '⏸ 关闭游戏' : '▶ 开启游戏' }}
</button>
</div>
{{-- 参数配置区域 --}}
<div class="p-5">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST">
@csrf
@php
$params = $game->params ?? [];
$labels = gameParamLabels($game->game_key);
@endphp
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@foreach ($params as $paramKey => $paramValue)
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">
{{ $meta['label'] }}
@if ($meta['unit'])
<span class="font-normal text-gray-400">{{ $meta['unit'] }}</span>
@endif
</label>
@if ($meta['type'] === 'boolean')
<select name="params[{{ $paramKey }}]"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
<option value="1" {{ $paramValue ? 'selected' : '' }}></option>
<option value="0" {{ !$paramValue ? 'selected' : '' }}></option>
</select>
@elseif ($meta['type'] === 'array')
<input type="text" name="params[{{ $paramKey }}]"
value="{{ implode(',', (array) $paramValue) }}"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400"
placeholder="多个值用逗号分隔">
@else
<input type="{{ $meta['type'] }}" name="params[{{ $paramKey }}]"
value="{{ $paramValue }}" step="{{ $meta['step'] ?? 1 }}"
min="{{ $meta['min'] ?? 0 }}"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
@endif
</div>
@endforeach
</div>
<div class="mt-4 flex items-center gap-3">
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 transition text-sm shadow-sm">
💾 保存参数
</button>
<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
@if ($games->isEmpty())
<div class="bg-white rounded-xl border border-gray-100 p-12 text-center text-gray-400">
暂无游戏配置,请先运行 <code class="bg-gray-100 px-2 py-1 rounded">php artisan db:seed
--class=GameConfigSeeder</code>
</div>
@endif
</div>
</div>
<script>
/**
* 切换游戏开启/关闭状态
*/
function toggleGame(gameKey, gameId) {
fetch(`/admin/game-configs/${gameId}/toggle`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(data => {
if (!data.ok) return;
const enabled = data.enabled;
const card = document.getElementById(`game-card-${gameKey}`);
const badge = document.getElementById(`badge-${gameKey}`);
const btn = document.getElementById(`toggle-btn-${gameKey}`);
const header = card?.querySelector('.flex.items-center.justify-between');
// 更新徽章
badge.textContent = enabled ? '运行中' : '已关闭';
badge.className = `text-xs px-2 py-0.5 rounded-full font-bold ${enabled
? 'bg-emerald-100 text-emerald-700'
: 'bg-gray-200 text-gray-500'}`;
// 更新按钮
btn.textContent = enabled ? '⏸ 关闭游戏' : '▶ 开启游戏';
btn.className = `px-5 py-2 rounded-lg font-bold text-sm transition shadow-sm ${enabled
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-emerald-500 hover:bg-emerald-600 text-white'}`;
// 更新头部背景
if (header) {
header.classList.toggle('bg-emerald-50', enabled);
header.classList.toggle('bg-gray-50', !enabled);
}
// 全局弹窗提示
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
@php
/**
* 返回各游戏参数的中文标签说明。
*
* @param string $gameKey
* @return array<string, array{label: string, type: string, unit: string}>
*/
function gameParamLabels(string $gameKey): array
{
return match ($gameKey) {
'baccarat' => [
'interval_minutes' => ['label' => '开局间隔', 'type' => 'number', 'unit' => '分钟', 'min' => 1],
'bet_window_seconds' => ['label' => '押注窗口', 'type' => 'number', 'unit' => '秒', 'min' => 10],
'min_bet' => ['label' => '最低押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'max_bet' => ['label' => '最高押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'payout_big' => ['label' => '大赔率(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'payout_small' => ['label' => '小赔率(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'payout_triple' => ['label' => '豹子赔率(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'kill_points' => ['label' => '庄家收割点数', 'type' => 'array', 'unit' => '逗号分隔'],
],
'slot_machine' => [
'cost_per_spin' => ['label' => '每次旋转消耗', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'house_edge_percent' => [
'label' => '庄家边际',
'type' => 'number',
'unit' => '%',
'min' => 0,
'step' => 0.1,
],
'daily_limit' => ['label' => '每日转动上限', 'type' => 'number', 'unit' => '次(0=不限)', 'min' => 0],
'jackpot_payout' => ['label' => '三个7赔率(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'triple_payout' => ['label' => '三💎赔率(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'same_payout' => ['label' => '其他三同(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'pair_payout' => ['label' => '两同赔率(1:N', 'type' => 'number', 'unit' => '', 'min' => 1],
'curse_enabled' => ['label' => '开启诅咒(三💀)', 'type' => 'boolean', 'unit' => ''],
],
'mystery_box' => [
'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],
'rare_max_reward' => ['label' => '稀有箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'trap_chance_percent' => [
'label' => '黑化箱概率',
'type' => 'number',
'unit' => '%',
'min' => 0,
'max' => 100,
],
],
'horse_racing' => [
'interval_minutes' => ['label' => '比赛间隔', 'type' => 'number', 'unit' => '分钟', 'min' => 5],
'bet_window_seconds' => ['label' => '押注窗口', 'type' => 'number', 'unit' => '秒', 'min' => 10],
'race_duration' => ['label' => '跑马动画时长', 'type' => 'number', 'unit' => '秒', 'min' => 10],
'horse_count' => ['label' => '参赛马匹数', 'type' => 'number', 'unit' => '匹', 'min' => 2, 'max' => 8],
'min_bet' => ['label' => '最低押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'max_bet' => ['label' => '最高押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'house_take_percent' => [
'label' => '庄家抽水',
'type' => 'number',
'unit' => '%',
'min' => 0,
'max' => 20,
],
],
'fortune_telling' => [
'free_count_per_day' => ['label' => '每日免费次数', 'type' => 'number', 'unit' => '次', 'min' => 0],
'extra_cost' => ['label' => '额外次数消耗', 'type' => 'number', 'unit' => '金币', 'min' => 0],
'buff_duration_hours' => ['label' => '加成持续时间', 'type' => 'number', 'unit' => '小时', 'min' => 1],
'jackpot_chance' => [
'label' => '上上签概率',
'type' => 'number',
'unit' => '%',
'min' => 0,
'max' => 100,
],
'good_chance' => ['label' => '上签概率', 'type' => 'number', 'unit' => '%', 'min' => 0, 'max' => 100],
'bad_chance' => ['label' => '下签概率', 'type' => 'number', 'unit' => '%', 'min' => 0, 'max' => 100],
'curse_chance' => [
'label' => '大凶签概率',
'type' => 'number',
'unit' => '%',
'min' => 0,
'max' => 100,
],
],
'fishing' => [
'fishing_cost' => ['label' => '每次抛竿消耗', 'type' => 'number', 'unit' => '金币', 'min' => 1],
'fishing_wait_min' => ['label' => '浮漂等待最短', 'type' => 'number', 'unit' => '秒', 'min' => 1],
'fishing_wait_max' => ['label' => '浮漂等待最长', 'type' => 'number', 'unit' => '秒', 'min' => 1],
'fishing_cooldown' => ['label' => '收竿后冷却时间', 'type' => 'number', 'unit' => '秒', 'min' => 10],
],
default => [],
};
}
@endphp