重构猜谜活动并统一聊天室答题通知

This commit is contained in:
pllx
2026-04-29 13:35:20 +08:00
parent 192259f0a4
commit fe3e74b5f8
34 changed files with 3369 additions and 1833 deletions
@@ -3,6 +3,11 @@
@section('title', '游戏管理')
@section('content')
@php
$riddleTypeOptions = \App\Models\Riddle::typeOptions();
$availableRooms = \App\Models\Room::orderBy('id')->get();
@endphp
<div class="space-y-6">
{{-- 页头 --}}
@@ -82,7 +87,8 @@
{{-- 参数配置区域 --}}
<div class="p-5">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST"
@if ($game->game_key === 'idiom') data-idiom-config-form @endif>
@csrf
@php
@@ -95,53 +101,140 @@
$paramKeys = array_values(array_filter($paramKeys, fn ($key) => ! in_array($key, $hiddenLegacyKeys, true)));
@endphp
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@foreach ($paramKeys as $paramKey)
@php
$paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '');
if ($game->game_key === 'mystery_box') {
$legacyFallbackMap = [
'normal_reward_min' => 'min_reward',
'normal_reward_max' => 'max_reward',
'rare_reward_min' => 'rare_min_reward',
'rare_reward_max' => 'rare_max_reward',
];
if (($paramValue === '' || $paramValue === null) && isset($legacyFallbackMap[$paramKey])) {
$paramValue = $params[$legacyFallbackMap[$paramKey]] ?? $paramValue;
}
}
@endphp
@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
@if ($game->game_key === 'idiom')
@php
$sharedConfig = gameRiddleSharedConfig($params);
$checkedRoomIds = collect($sharedConfig['room_ids'])->map(fn ($roomId) => (int) $roomId)->all();
@endphp
<div class="space-y-4">
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 p-4 text-xs leading-6 text-indigo-700">
猜成语与脑筋急转弯共用同一套奖励、过期时间、自动出题间隔与参与房间范围配置。
手动出题时再单独选择题型即可。
</div>
@endforeach
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/70 p-4" data-idiom-config-card>
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-bold text-slate-800">猜谜活动公共设置</div>
<div class="text-xs text-slate-500">以下参数会同时作用于猜成语与脑筋急转弯。</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@foreach ($riddleTypeOptions as $typeLabel)
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-500 shadow-sm">{{ $typeLabel }}</span>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">答对奖励金币</label>
<input type="number" name="params[reward_gold]"
value="{{ old('params.reward_gold', $sharedConfig['reward_gold']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">答对奖励经验</label>
<input type="number" name="params[reward_exp]"
value="{{ old('params.reward_exp', $sharedConfig['reward_exp']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">题目过期时间</label>
<input type="number" name="params[expire_minutes]"
value="{{ old('params.expire_minutes', $sharedConfig['expire_minutes']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示不过期。</p>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">自动出题间隔</label>
<input type="number" name="params[auto_start_interval]"
value="{{ old('params.auto_start_interval', $sharedConfig['auto_start_interval']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示仅手动出题。</p>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">参与房间模式</label>
<select name="params[room_scope_mode]"
data-idiom-room-mode
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="all" @selected($sharedConfig['room_mode'] === 'all')>全部房间</option>
<option value="single" @selected($sharedConfig['room_mode'] === 'single')>单选房间</option>
<option value="multiple" @selected($sharedConfig['room_mode'] === 'multiple')>多选房间</option>
</select>
</div>
<div class="xl:col-span-2">
<label class="mb-2 block text-xs font-bold text-gray-600" data-idiom-room-label>参与房间</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-dashed border-slate-200 bg-white p-3 lg:grid-cols-3">
@foreach ($availableRooms as $room)
<label class="flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm text-slate-600">
<input type="checkbox"
name="params[room_ids][]"
value="{{ $room->id }}"
@checked(in_array((int) $room->id, $checkedRoomIds, true))
data-idiom-room-checkbox
class="rounded border-slate-300 text-indigo-600">
<span>#{{ $room->id }} {{ $room->name }}</span>
</label>
@endforeach
</div>
<p class="mt-2 text-xs text-gray-400">单选模式下只保留一个房间,多选模式可同时勾选多个房间。</p>
</div>
</div>
</div>
</div>
@else
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
@foreach ($paramKeys as $paramKey)
@php
$paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '');
if ($game->game_key === 'mystery_box') {
$legacyFallbackMap = [
'normal_reward_min' => 'min_reward',
'normal_reward_max' => 'max_reward',
'rare_reward_min' => 'rare_min_reward',
'rare_reward_max' => 'rare_max_reward',
];
if (($paramValue === '' || $paramValue === null) && isset($legacyFallbackMap[$paramKey])) {
$paramValue = $params[$legacyFallbackMap[$paramKey]] ?? $paramValue;
}
}
@endphp
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">
{{ $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 rounded-lg border border-gray-300 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 rounded-lg border border-gray-300 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 rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
@endif
</div>
@endforeach
</div>
@endif
<div class="mt-4 flex items-center gap-3">
<button type="submit"
@@ -152,6 +245,32 @@
</div>
</form>
@if ($game->game_key === 'idiom')
<div class="mt-4 border-t border-gray-100 pt-4">
<div class="mb-3 text-xs font-bold text-gray-600">🧩 手动出题</div>
<div class="flex flex-wrap items-center gap-3">
<select id="idiom-manual-room"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-slate-700">
@foreach ($availableRooms as $room)
<option value="{{ $room->id }}">#{{ $room->id }} {{ $room->name }}</option>
@endforeach
</select>
<select id="idiom-manual-type"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-slate-700">
@foreach ($riddleTypeOptions as $typeKey => $typeLabel)
<option value="{{ $typeKey }}">{{ $typeLabel }}</option>
@endforeach
</select>
<button type="button" id="idiom-manual-start-btn"
data-idiom-start-url="{{ route('riddle-quiz.start') }}"
class="rounded-lg bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:opacity-90">
立即出题
</button>
<span class="text-xs text-gray-400">先选房间,再选题型,后台会按对应题型配置发题。</span>
</div>
</div>
@endif
{{-- 神秘箱子:手动投放区域 --}}
@if ($game->game_key === 'mystery_box')
<div class="mt-4 pt-4 border-t border-gray-100">
@@ -416,4 +535,136 @@
default => [],
};
}
/**
* 解析猜谜活动公共配置,并兼容旧版题型拆分配置。
*
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
*/
function gameRiddleSharedConfig(array $params): array
{
$fallbackTypeConfig = collect((array) ($params['type_configs'] ?? []))
->first(fn ($typeConfig) => is_array($typeConfig) && $typeConfig !== [], []);
$roomMode = (string) ($params['room_scope_mode'] ?? ($fallbackTypeConfig['room_mode'] ?? 'single'));
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
$roomMode = 'single';
}
return [
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($fallbackTypeConfig['reward_gold'] ?? 50))),
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($fallbackTypeConfig['reward_exp'] ?? 30))),
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($fallbackTypeConfig['expire_minutes'] ?? 5))),
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($fallbackTypeConfig['auto_start_interval'] ?? 0))),
'room_mode' => $roomMode,
'room_ids' => collect((array) ($params['room_ids'] ?? ($fallbackTypeConfig['room_ids'] ?? [])))
->map(fn ($roomId) => (int) $roomId)
->filter(fn ($roomId) => $roomId > 0)
->unique()
->values()
->all(),
];
}
@endphp
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
function showAdminAlert(message, title = '提示', icon = '️') {
if (window.adminDialog?.alert) {
window.adminDialog.alert(message, title, icon);
return;
}
window.alert(message);
}
document.querySelectorAll('[data-idiom-config-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
let hasValidationError = false;
const modeSelect = this.querySelector('[data-idiom-room-mode]');
const roomCheckboxes = Array.from(this.querySelectorAll('[data-idiom-room-checkbox]'));
const checkedRooms = roomCheckboxes.filter(function (checkbox) {
return checkbox.checked;
});
if (modeSelect) {
if (modeSelect.value === 'single' && checkedRooms.length > 1) {
showAdminAlert('猜谜活动处于单选房间模式时,只能勾选一个房间。', '房间选择有误', '⚠️');
hasValidationError = true;
}
if (modeSelect.value === 'single' && checkedRooms.length === 0) {
const firstRoomCheckbox = roomCheckboxes[0];
if (firstRoomCheckbox) {
firstRoomCheckbox.checked = true;
}
}
if (modeSelect.value === 'multiple' && checkedRooms.length === 0) {
showAdminAlert('猜谜活动处于多选房间模式时,请至少选择一个房间。', '房间选择有误', '⚠️');
hasValidationError = true;
}
}
if (hasValidationError) {
event.preventDefault();
}
});
});
const manualStartButton = document.getElementById('idiom-manual-start-btn');
if (!manualStartButton) {
return;
}
manualStartButton.addEventListener('click', function () {
const startUrl = this.getAttribute('data-idiom-start-url') || '';
const roomId = Number.parseInt(document.getElementById('idiom-manual-room')?.value || '0', 10);
const quizType = document.getElementById('idiom-manual-type')?.value || '';
if (!startUrl || roomId <= 0 || !quizType) {
showAdminAlert('请先选择房间和题型。', '手动出题', '⚠️');
return;
}
const button = this;
button.disabled = true;
button.textContent = '出题中...';
fetch(startUrl, {
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: roomId,
quiz_type: quizType,
}),
})
.then(function (response) {
return response.json();
})
.then(function (response) {
if (response.status === 'success') {
showAdminAlert('题目已发送到目标房间。', '手动出题成功', '✅');
return;
}
showAdminAlert(response.message || '出题失败', '手动出题失败', '❌');
})
.catch(function () {
showAdminAlert('网络错误,出题失败', '手动出题失败', '🌐');
})
.finally(function () {
button.disabled = false;
button.textContent = '立即出题';
});
});
});
</script>
@endpush