重构猜谜活动并统一聊天室答题通知
This commit is contained in:
@@ -158,7 +158,7 @@ enum CurrencySource: string
|
||||
/** 购买头像框消耗(扣除金币) */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
|
||||
/** 猜成语游戏奖励 */
|
||||
/** 猜谜活动奖励 */
|
||||
case GAME_REWARD = 'game_reward';
|
||||
|
||||
/**
|
||||
@@ -213,7 +213,7 @@ enum CurrencySource: string
|
||||
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
|
||||
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||
self::GAME_REWARD => '猜成语奖励',
|
||||
self::GAME_REWARD => '猜谜活动奖励',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语答题结果广播事件
|
||||
* 文件功能:猜谜活动答题结果广播事件
|
||||
*
|
||||
* 用户答对成语时广播,通知房间内所有用户结果。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
* 用户答对题目时广播,通知房间内所有用户结果。
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
@@ -18,27 +14,29 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class IdiomGameAnswered implements ShouldBroadcastNow
|
||||
/**
|
||||
* 类功能:向指定房间广播猜谜活动答题结果。
|
||||
*/
|
||||
class RiddleGameAnswered implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int $roomId 房间 ID
|
||||
* @param int $roundId 游戏回合 ID
|
||||
* @param string $answer 正确答案
|
||||
* @param string $winnerUsername 答对的用户名
|
||||
* @param int $rewardGold 获得的金币
|
||||
* @param int $rewardExp 获得的经验
|
||||
* 方法功能:构造答题成功广播事件载荷。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly int $roundId,
|
||||
public readonly string $quizType,
|
||||
public readonly string $answer,
|
||||
public readonly string $winnerUsername,
|
||||
public readonly int $rewardGold = 0,
|
||||
public readonly int $rewardExp = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:声明广播频道。
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
@@ -46,10 +44,22 @@ class IdiomGameAnswered implements ShouldBroadcastNow
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:声明广播数据。
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
|
||||
|
||||
return [
|
||||
'round_id' => $this->roundId,
|
||||
'quiz_type' => $this->quizType,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'quiz_round_id' => $this->roundId,
|
||||
'quiz_answer' => $this->answer,
|
||||
'quiz_reward_gold' => $this->rewardGold,
|
||||
'quiz_reward_exp' => $this->rewardExp,
|
||||
'quiz_round_ended_id' => $this->roundId,
|
||||
'answer' => $this->answer,
|
||||
'winner_username' => $this->winnerUsername,
|
||||
'reward_gold' => $this->rewardGold,
|
||||
@@ -1,13 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语游戏开始广播事件
|
||||
* 文件功能:猜谜活动开始广播事件
|
||||
*
|
||||
* 管理员手动出题时触发,广播成语提示到聊天室,前端显示提示+答题按钮。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
* 管理员手动出题或系统自动出题时触发,广播提示到聊天室。
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
@@ -18,19 +14,19 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class IdiomGameStarted implements ShouldBroadcastNow
|
||||
/**
|
||||
* 类功能:向指定房间广播新一轮猜谜活动题目。
|
||||
*/
|
||||
class RiddleGameStarted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $hint 谜语提示
|
||||
* @param int $roundId 游戏回合 ID(前端提交答案时带上)
|
||||
* @param int $rewardGold 答对奖励金币
|
||||
* @param int $rewardExp 答对奖励经验
|
||||
* 方法功能:构造新回合广播事件载荷。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $quizType,
|
||||
public readonly string $hint,
|
||||
public readonly int $roundId,
|
||||
public readonly int $rewardGold = 0,
|
||||
@@ -38,7 +34,7 @@ class IdiomGameStarted implements ShouldBroadcastNow
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道
|
||||
* 方法功能:声明广播频道。
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
@@ -48,16 +44,24 @@ class IdiomGameStarted implements ShouldBroadcastNow
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据
|
||||
* 方法功能:声明广播数据。
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
|
||||
|
||||
return [
|
||||
'round_id' => $this->roundId,
|
||||
'quiz_type' => $this->quizType,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'quiz_round_id' => $this->roundId,
|
||||
'quiz_hint' => $this->hint,
|
||||
'quiz_reward_gold' => $this->rewardGold,
|
||||
'quiz_reward_exp' => $this->rewardExp,
|
||||
'hint' => $this->hint,
|
||||
'reward_gold' => $this->rewardGold,
|
||||
'reward_exp' => $this->rewardExp,
|
||||
'message' => "🧩 猜成语时间!{$this->hint}",
|
||||
'message' => "📣 【猜谜活动·{$quizTypeLabel}】第 #{$this->roundId} 题开始!题面:{$this->hint}",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语题库后台管理控制器
|
||||
* 提供成语题目的列表展示、创建、编辑、删除、启用/禁用功能
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Idiom;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责猜成语题库与后台参数管理。
|
||||
*/
|
||||
class IdiomController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:显示所有成语题目列表。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$idioms = Idiom::orderBy('sort')->orderBy('id')->get();
|
||||
|
||||
return view('admin.idioms.index', compact('idioms'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建新的成语题目。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'answer' => 'required|string|max:50',
|
||||
'hint' => 'required|string|max:255',
|
||||
'sort' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
Idiom::create($data);
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', '成语题目已添加!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新已有成语题目。
|
||||
*/
|
||||
public function update(Request $request, Idiom $idiom): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'answer' => 'required|string|max:50',
|
||||
'hint' => 'required|string|max:255',
|
||||
'sort' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$idiom->update($data);
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', "题目「{$idiom->answer}」已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:通过 AJAX 切换题目的启用状态。
|
||||
*/
|
||||
public function toggle(Idiom $idiom): JsonResponse
|
||||
{
|
||||
$idiom->update(['is_active' => ! $idiom->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_active' => $idiom->is_active,
|
||||
'message' => $idiom->is_active ? "「{$idiom->answer}」已启用" : "「{$idiom->answer}」已禁用",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除指定成语题目。
|
||||
*/
|
||||
public function destroy(Idiom $idiom): RedirectResponse
|
||||
{
|
||||
$answer = $idiom->answer;
|
||||
$idiom->delete();
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', "题目「{$answer}」已删除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保存猜成语游戏参数而不覆盖其他游戏配置字段。
|
||||
*/
|
||||
public function saveSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'reward_gold' => 'required|integer|min:0',
|
||||
'reward_exp' => 'required|integer|min:0',
|
||||
'auto_start_interval' => 'required|integer|min:0',
|
||||
'expire_minutes' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$config = GameConfig::firstOrCreate(
|
||||
['game_key' => 'idiom'],
|
||||
['name' => '猜成语', 'icon' => '🧩', 'enabled' => false],
|
||||
);
|
||||
|
||||
// 合并现有 params,只覆盖本次后台提交的字段,避免误删其他游戏参数。
|
||||
$existingParams = $config->params ?? [];
|
||||
$config->params = array_merge($existingParams, [
|
||||
'reward_gold' => (int) $data['reward_gold'],
|
||||
'reward_exp' => (int) $data['reward_exp'],
|
||||
'auto_start_interval' => (int) $data['auto_start_interval'],
|
||||
'expire_minutes' => (int) $data['expire_minutes'],
|
||||
]);
|
||||
$config->save();
|
||||
$config->clearCache();
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', '游戏参数已保存!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动题库后台管理控制器
|
||||
*
|
||||
* 负责后台题库的列表筛选、题目增删改和启用状态切换。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Riddle;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:统一处理猜谜活动题库的后台管理动作。
|
||||
*/
|
||||
class RiddleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:显示题库列表,并支持按题型和关键词筛选。
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$typeOptions = Riddle::typeOptions();
|
||||
$selectedType = trim((string) $request->query('type', ''));
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
|
||||
$idiomQuery = Riddle::query();
|
||||
|
||||
if ($selectedType !== '' && isset($typeOptions[$selectedType])) {
|
||||
// 题型筛选只接受系统支持值,避免非法参数污染查询。
|
||||
$idiomQuery->ofType($selectedType);
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
// 关键词同时匹配答案与提示,方便后台快速定位题目。
|
||||
$idiomQuery->where(function ($query) use ($keyword): void {
|
||||
$query->where('answer', 'like', '%'.$keyword.'%')
|
||||
->orWhere('hint', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
}
|
||||
|
||||
$idioms = $idiomQuery
|
||||
->orderBy('type')
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$typeStats = Riddle::query()
|
||||
->selectRaw('type, COUNT(*) as total')
|
||||
->groupBy('type')
|
||||
->pluck('total', 'type')
|
||||
->all();
|
||||
|
||||
return view('admin.riddles.index', [
|
||||
'idioms' => $idioms,
|
||||
'typeOptions' => $typeOptions,
|
||||
'selectedType' => $selectedType,
|
||||
'keyword' => $keyword,
|
||||
'typeStats' => $typeStats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建新的猜谜活动题目。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateRiddlePayload($request);
|
||||
|
||||
// 新增时默认启用,便于后台批量补题后立即可用。
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
Riddle::create($data);
|
||||
|
||||
$typeLabel = Riddle::labelForType($data['type']);
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "{$typeLabel}题目已添加!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新已有题目内容与题型。
|
||||
*/
|
||||
public function update(Request $request, Riddle $idiom): RedirectResponse
|
||||
{
|
||||
$data = $this->validateRiddlePayload($request);
|
||||
|
||||
// 编辑时显式按复选框结果落库,避免旧状态残留。
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$idiom->update($data);
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "题目「{$idiom->answer}」已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:通过 AJAX 切换题目的启用状态。
|
||||
*/
|
||||
public function toggle(Riddle $idiom): JsonResponse
|
||||
{
|
||||
// 开关按钮只变更启用状态,不改动其他题库字段。
|
||||
$idiom->update(['is_active' => ! $idiom->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_active' => $idiom->is_active,
|
||||
'message' => $idiom->is_active ? "「{$idiom->answer}」已启用" : "「{$idiom->answer}」已禁用",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除指定题目。
|
||||
*/
|
||||
public function destroy(Request $request, Riddle $idiom): RedirectResponse
|
||||
{
|
||||
$answer = $idiom->answer;
|
||||
$idiom->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "题目「{$answer}」已删除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:校验后台题库保存载荷。
|
||||
*
|
||||
* @return array{type:string,answer:string,hint:string,sort:int}
|
||||
*/
|
||||
private function validateRiddlePayload(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'type' => ['required', 'string', Rule::in(Riddle::supportedTypes())],
|
||||
'answer' => ['required', 'string', 'max:120'],
|
||||
'hint' => ['required', 'string', 'max:255'],
|
||||
'sort' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保留列表筛选参数,方便后台操作后返回原筛选结果。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildIndexFilters(Request $request): array
|
||||
{
|
||||
$filters = [];
|
||||
$type = trim((string) $request->input('redirect_type', $request->query('type', '')));
|
||||
$keyword = trim((string) $request->input('redirect_keyword', $request->query('keyword', '')));
|
||||
|
||||
if ($type !== '') {
|
||||
$filters['type'] = $type;
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$filters['keyword'] = $keyword;
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语游戏控制器
|
||||
*
|
||||
* 负责出题、答题、查询当前回合状态等游戏核心逻辑。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\IdiomGameAnswered;
|
||||
use App\Events\IdiomGameStarted;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Idiom;
|
||||
use App\Models\IdiomGameRound;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\IdiomGameService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 类功能:处理猜成语出题、答题和当前回合查询。
|
||||
*/
|
||||
class IdiomQuizController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入猜成语所需的状态、结算与奖励服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly IdiomGameService $idiomGameService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 管理员手动出题(POST)
|
||||
*/
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 权限校验:仅站长或具备后台身份的管理用户可手动出题。
|
||||
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
|
||||
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
// 先清理该房间已超时但未结算的旧回合,避免它们长期卡住新题。
|
||||
$this->idiomGameService->expireActiveRoundsForRoom($roomId);
|
||||
|
||||
// 清理后再检查是否还有真正进行中的回合。
|
||||
$activeRound = IdiomGameRound::where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
if ($activeRound) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '当前房间已有进行中的猜成语题目,请先结束当前回合。',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 随机选一道启用的题目
|
||||
$idiom = Idiom::where('is_active', true)->inRandomOrder()->first();
|
||||
if (! $idiom) {
|
||||
return response()->json(['status' => 'error', 'message' => '题库中没有可用的题目,请先在后台添加。'], 400);
|
||||
}
|
||||
|
||||
// 读取游戏配置,保证手动出题和自动出题使用同一组奖励参数。
|
||||
$config = GameConfig::forGame('idiom');
|
||||
$params = $config?->params ?? [];
|
||||
$rewardGold = (int) ($params['reward_gold'] ?? 50);
|
||||
$rewardExp = (int) ($params['reward_exp'] ?? 30);
|
||||
|
||||
// 创建新回合,并以 started_at 作为后续过期计时的起点。
|
||||
$round = IdiomGameRound::create([
|
||||
'room_id' => $roomId,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
'reward_gold' => $rewardGold,
|
||||
'reward_exp' => $rewardExp,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// 广播到聊天室,让前端即时展示题目提示与答题按钮。
|
||||
broadcast(new IdiomGameStarted(
|
||||
roomId: $roomId,
|
||||
hint: $idiom->hint,
|
||||
roundId: $round->id,
|
||||
rewardGold: $rewardGold,
|
||||
rewardExp: $rewardExp,
|
||||
));
|
||||
|
||||
// 同时推一条公屏消息,兼容现有聊天窗口的消息渲染链路。
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '星海小博士',
|
||||
'to_user' => '大家',
|
||||
'content' => "🧩 猜成语时间!{$idiom->hint}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#7c3aed',
|
||||
'action' => '',
|
||||
'idiom_game_round_id' => $round->id,
|
||||
'idiom_reward_gold' => $rewardGold,
|
||||
'idiom_reward_exp' => $rewardExp,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new \App\Events\MessageSent($roomId, $msg));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'round_id' => $round->id,
|
||||
'hint' => $idiom->hint,
|
||||
'reward_gold' => $rewardGold,
|
||||
'reward_exp' => $rewardExp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:提交当前猜成语回合的答案。
|
||||
*/
|
||||
public function answer(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
$roundId = (int) $request->input('round_id');
|
||||
$userAnswer = trim((string) $request->input('answer', ''));
|
||||
$roomId = (int) $request->input('room_id');
|
||||
|
||||
if ($roundId <= 0 || $userAnswer === '' || $roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
|
||||
}
|
||||
|
||||
// 查找回合
|
||||
$round = IdiomGameRound::with('idiom')->find($roundId);
|
||||
if (! $round || $round->room_id !== $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
|
||||
}
|
||||
|
||||
// 判题前先处理超时,避免用户在无效回合上继续抢答。
|
||||
if ($this->idiomGameService->expireRound($round)) {
|
||||
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
|
||||
}
|
||||
|
||||
if ($round->status !== 'active') {
|
||||
if ($round->status === 'answered') {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
|
||||
}
|
||||
|
||||
// 校验答案时忽略空格与大小写差异,降低正常输入误判率。
|
||||
$normalizedAnswer = str_replace(' ', '', $userAnswer);
|
||||
$normalizedCorrect = str_replace(' ', '', $round->idiom->answer);
|
||||
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '答案不正确,再想想!',
|
||||
], 200);
|
||||
}
|
||||
|
||||
// 答对后立即加 Redis 锁,防止多人并发提交造成重复领奖。
|
||||
$lockKey = "idiom:answer_lock:{$roundId}";
|
||||
if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
\Illuminate\Support\Facades\Redis::expire($lockKey, 10);
|
||||
|
||||
// 抢答成功后立刻封盘,确保后续请求统一看到 answered 状态。
|
||||
$round->update([
|
||||
'status' => 'answered',
|
||||
'winner_id' => $user->id,
|
||||
'winner_username' => $user->username,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
// 奖励仍沿用现有金币与经验发放逻辑,避免行为回归。
|
||||
if ($round->reward_gold > 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $round->reward_gold,
|
||||
\App\Enums\CurrencySource::GAME_REWARD,
|
||||
"猜成语答对「{$round->idiom->answer}」奖励",
|
||||
$roomId,
|
||||
);
|
||||
}
|
||||
if ($round->reward_exp > 0) {
|
||||
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 广播结果,让房间内用户立即看到答题成功公告。
|
||||
broadcast(new IdiomGameAnswered(
|
||||
roomId: $roomId,
|
||||
roundId: $round->id,
|
||||
answer: $round->idiom->answer,
|
||||
winnerUsername: $user->username,
|
||||
rewardGold: $round->reward_gold,
|
||||
rewardExp: $round->reward_exp,
|
||||
));
|
||||
|
||||
// 存聊天记录但不再次广播,避免和上面的实时事件重复刷屏。
|
||||
$resultMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '星海小博士',
|
||||
'to_user' => '大家',
|
||||
'content' => "🎉 【{$user->username}】率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => 'idiom_result',
|
||||
'winner_username' => $user->username,
|
||||
'idiom_answer' => $round->idiom->answer,
|
||||
'idiom_result_reward_gold' => $round->reward_gold,
|
||||
'idiom_result_reward_exp' => $round->reward_exp,
|
||||
'idiom_game_round_ended_id' => $round->id,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $resultMsg);
|
||||
|
||||
\Illuminate\Support\Facades\Redis::del($lockKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'data' => [
|
||||
'answer' => $round->idiom->answer,
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询当前房间的进行中回合。
|
||||
*/
|
||||
public function current(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
$round = IdiomGameRound::with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
|
||||
if (! $round) {
|
||||
return response()->json(['status' => 'success', 'data' => null]);
|
||||
}
|
||||
|
||||
// 当前接口不再暴露已过期回合,避免前端继续显示无效答题入口。
|
||||
if ($this->idiomGameService->expireRound($round)) {
|
||||
return response()->json(['status' => 'success', 'data' => null]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'round_id' => $round->id,
|
||||
'hint' => $round->idiom?->hint ?? '',
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动控制器
|
||||
*
|
||||
* 负责兼容现有 idiom-quiz 路由,同时支持猜成语与脑筋急转弯
|
||||
* 两类题型的开题、答题与当前回合查询。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\RiddleGameAnswered;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Riddle;
|
||||
use App\Models\RiddleGameRound;
|
||||
use App\Services\RiddleGameService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:处理猜谜活动开题、答题和当前回合读取。
|
||||
*/
|
||||
class RiddleQuizController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入猜谜活动所需的服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly RiddleGameService $riddleGameService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:管理员手动为指定房间与题型发起一轮猜谜活动。
|
||||
*/
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 仅站长或具备后台职务的管理用户可手动开题。
|
||||
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
|
||||
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
// 兼容后台新字段 quiz_type 与旧字段 type,两边都允许触发手动出题。
|
||||
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
// 猜谜活动总开关关闭时,直接返回明确提示,避免误报成“题库为空”。
|
||||
if (! GameConfig::isEnabled(Riddle::TYPE_IDIOM)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '猜谜活动未开启,请先到游戏管理中开启后再出题。',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 后台手动出题允许覆盖当前同题型回合,避免管理员还要先人工结束上一题。
|
||||
$this->riddleGameService->endActiveRoundsForRoom($roomId, $quizType);
|
||||
|
||||
$round = $this->riddleGameService->startRound($roomId, $quizType);
|
||||
if (! $round) {
|
||||
if (! $this->riddleGameService->pickRandomQuestion($quizType)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前题型题库中没有可用题目,请先在后台添加。'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '当前题型暂时无法出题,请检查游戏配置与参与房间设置。'], 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
|
||||
'round_id' => $round->id,
|
||||
'quiz_round_id' => $round->id,
|
||||
'hint' => $round->idiom?->hint ?? '',
|
||||
'quiz_hint' => $round->idiom?->hint ?? '',
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:提交当前猜谜活动回合的答案。
|
||||
*/
|
||||
public function answer(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
$roundId = (int) $request->input('round_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
|
||||
$userAnswer = trim((string) $request->input('answer', ''));
|
||||
|
||||
if ($roundId <= 0 || $roomId <= 0 || $userAnswer === '') {
|
||||
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
|
||||
}
|
||||
|
||||
$round = RiddleGameRound::with('idiom')->find($roundId);
|
||||
if (! $round || $round->room_id !== $roomId || $round->quiz_type !== $quizType) {
|
||||
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
|
||||
}
|
||||
|
||||
// 判题前先做超时结算,避免用户继续抢答无效回合。
|
||||
if ($this->riddleGameService->expireRound($round)) {
|
||||
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
|
||||
}
|
||||
|
||||
if ($round->status !== 'active') {
|
||||
if ($round->status === 'answered') {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
|
||||
}
|
||||
|
||||
// 答案对比忽略空格与大小写,减少正常输入误判。
|
||||
$normalizedAnswer = str_replace(' ', '', $userAnswer);
|
||||
$normalizedCorrect = str_replace(' ', '', (string) $round->idiom?->answer);
|
||||
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '答案不正确,再想想!',
|
||||
]);
|
||||
}
|
||||
|
||||
$lockKey = "riddle:answer_lock:{$roundId}";
|
||||
if (! Redis::setnx($lockKey, 1)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
|
||||
Redis::expire($lockKey, 10);
|
||||
|
||||
// 抢答成功后立即封盘,确保并发请求读到统一状态。
|
||||
$round->update([
|
||||
'status' => 'answered',
|
||||
'winner_id' => $user->id,
|
||||
'winner_username' => $user->username,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
if ($round->reward_gold > 0) {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'gold',
|
||||
$round->reward_gold,
|
||||
\App\Enums\CurrencySource::GAME_REWARD,
|
||||
$this->riddleGameService->buildRewardDescription($round),
|
||||
$roomId,
|
||||
);
|
||||
}
|
||||
|
||||
if ($round->reward_exp > 0) {
|
||||
// 经验奖励仍沿用现有字段,避免引入额外奖励服务改动。
|
||||
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
broadcast(new RiddleGameAnswered(
|
||||
roomId: $roomId,
|
||||
roundId: $round->id,
|
||||
quizType: $round->quiz_type,
|
||||
answer: (string) $round->idiom?->answer,
|
||||
winnerUsername: $user->username,
|
||||
rewardGold: $round->reward_gold,
|
||||
rewardExp: $round->reward_exp,
|
||||
));
|
||||
|
||||
$quizTypeLabel = $this->riddleGameService->getQuizTypeLabel($round->quiz_type);
|
||||
$resultMsg = [
|
||||
'id' => app(\App\Services\ChatStateService::class)->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🎉 【猜谜活动·{$quizTypeLabel}】{$user->username} 率先答对「{$round->idiom?->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => 'idiom_result',
|
||||
'winner_username' => $user->username,
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'quiz_answer' => (string) $round->idiom?->answer,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
'quiz_round_id' => $round->id,
|
||||
'quiz_round_ended_id' => $round->id,
|
||||
'idiom_answer' => (string) $round->idiom?->answer,
|
||||
'idiom_result_reward_gold' => $round->reward_gold,
|
||||
'idiom_result_reward_exp' => $round->reward_exp,
|
||||
'idiom_game_round_ended_id' => $round->id,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
app(\App\Services\ChatStateService::class)->pushMessage($roomId, $resultMsg);
|
||||
|
||||
Redis::del($lockKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'data' => [
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'answer' => (string) $round->idiom?->answer,
|
||||
'quiz_answer' => (string) $round->idiom?->answer,
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询当前房间指定题型的进行中回合。
|
||||
*/
|
||||
public function current(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
$round = $this->riddleGameService->findActiveRound($roomId, $quizType);
|
||||
if (! $round) {
|
||||
return response()->json(['status' => 'success', 'data' => null]);
|
||||
}
|
||||
|
||||
if ($this->riddleGameService->expireRound($round)) {
|
||||
return response()->json(['status' => 'success', 'data' => null]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
|
||||
'round_id' => $round->id,
|
||||
'quiz_round_id' => $round->id,
|
||||
'hint' => $round->idiom?->hint ?? '',
|
||||
'quiz_hint' => $round->idiom?->hint ?? '',
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -309,10 +309,18 @@ class UserController extends Controller
|
||||
{
|
||||
$user = Auth::user();
|
||||
$data = $request->validated();
|
||||
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
|
||||
->map(function (string $sender): string {
|
||||
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
|
||||
return $sender === '猜谜活动' ? '猜成语' : $sender;
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$preferences = [
|
||||
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
|
||||
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
|
||||
'blocked_system_senders' => $blockedSystemSenders,
|
||||
'sound_muted' => (bool) $data['sound_muted'],
|
||||
];
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class UpdateChatPreferencesRequest extends FormRequest
|
||||
'blocked_system_senders' => ['nullable', 'array'],
|
||||
'blocked_system_senders.*' => [
|
||||
'string',
|
||||
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
|
||||
Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子']),
|
||||
],
|
||||
'sound_muted' => ['required', 'boolean'],
|
||||
];
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语题目模型
|
||||
*
|
||||
* 对应 idioms 表,每条记录是一道成语题目 + 谜语提示。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Idiom extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'answer',
|
||||
'hint',
|
||||
'is_active',
|
||||
'sort',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动题库模型
|
||||
*
|
||||
* 对应 idioms 表,统一承载成语题与脑筋急转弯题目。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 类功能:统一管理猜谜活动的题目、答案、提示与题型。
|
||||
*/
|
||||
class Riddle extends Model
|
||||
{
|
||||
/**
|
||||
* 属性功能:显式绑定历史题库表名,避免类名重命名后推导到错误表。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'idioms';
|
||||
|
||||
/**
|
||||
* 常量功能:声明成语题题型标识。
|
||||
*/
|
||||
public const TYPE_IDIOM = 'idiom';
|
||||
|
||||
/**
|
||||
* 常量功能:声明脑筋急转弯题型标识。
|
||||
*/
|
||||
public const TYPE_BRAIN_TEASER = 'brain_teaser';
|
||||
|
||||
/**
|
||||
* 方法功能:声明允许批量赋值的题库字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'answer',
|
||||
'hint',
|
||||
'is_active',
|
||||
'sort',
|
||||
];
|
||||
|
||||
/**
|
||||
* 方法功能:定义题库字段的类型转换规则。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回系统支持的全部题型。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function supportedTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_IDIOM,
|
||||
self::TYPE_BRAIN_TEASER,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断给定题型是否属于系统支持范围。
|
||||
*/
|
||||
public static function isSupportedType(string $type): bool
|
||||
{
|
||||
return in_array($type, self::supportedTypes(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:根据题型返回面向用户的中文名称。
|
||||
*/
|
||||
public static function labelForType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_BRAIN_TEASER => '脑筋急转弯',
|
||||
default => '猜成语',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回后台表单可直接使用的题型键值对。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function typeOptions(): array
|
||||
{
|
||||
return collect(self::supportedTypes())
|
||||
->mapWithKeys(fn (string $type): array => [$type => self::labelForType($type)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回题型对应的活动标题。
|
||||
*/
|
||||
public static function activityLabelForType(string $type): string
|
||||
{
|
||||
return '猜谜活动·'.self::labelForType($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:按题型筛选题库记录。
|
||||
*/
|
||||
public function scopeOfType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('type', self::isSupportedType($type) ? $type : self::TYPE_IDIOM);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语游戏回合模型
|
||||
* 文件功能:猜谜活动回合模型
|
||||
*
|
||||
* 每次出题对应一个回合,记录题目、状态、奖励和获胜者。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
* 每次出题对应一个回合,记录题型、题目、状态、奖励和获胜者。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
@@ -16,10 +12,17 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 类功能:记录猜成语每一轮的题目、奖励与结算状态。
|
||||
* 类功能:记录猜谜活动每一轮的题型、奖励与结算状态。
|
||||
*/
|
||||
class IdiomGameRound extends Model
|
||||
class RiddleGameRound extends Model
|
||||
{
|
||||
/**
|
||||
* 属性功能:显式绑定历史回合表名,避免类名重命名后推导到错误表。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'idiom_game_rounds';
|
||||
|
||||
/**
|
||||
* 方法功能:声明可批量赋值的回合字段。
|
||||
*
|
||||
@@ -28,6 +31,7 @@ class IdiomGameRound extends Model
|
||||
protected $fillable = [
|
||||
'room_id',
|
||||
'idiom_id',
|
||||
'quiz_type',
|
||||
'status',
|
||||
'reward_gold',
|
||||
'reward_exp',
|
||||
@@ -45,6 +49,8 @@ class IdiomGameRound extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'room_id' => 'integer',
|
||||
'idiom_id' => 'integer',
|
||||
'reward_gold' => 'integer',
|
||||
'reward_exp' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
@@ -53,10 +59,10 @@ class IdiomGameRound extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:关联本回合对应的成语题目。
|
||||
* 方法功能:关联本回合对应的猜谜题目。
|
||||
*/
|
||||
public function idiom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Idiom::class);
|
||||
return $this->belongsTo(Riddle::class);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语游戏回合服务
|
||||
*
|
||||
* 统一处理猜成语回合的过期判定、超时结算和系统消息广播,
|
||||
* 避免控制器与定时任务各自维护一套回合状态逻辑。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\IdiomGameRound;
|
||||
|
||||
/**
|
||||
* 类功能:提供猜成语回合过期与结算能力。
|
||||
*/
|
||||
class IdiomGameService
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:读取猜成语题目的有效时长配置,单位分钟。
|
||||
*/
|
||||
public function getExpireMinutes(): int
|
||||
{
|
||||
return max(0, (int) GameConfig::param('idiom', 'expire_minutes', 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断指定回合是否已经超过有效时长。
|
||||
*/
|
||||
public function isRoundExpired(IdiomGameRound $round): bool
|
||||
{
|
||||
$expireMinutes = $this->getExpireMinutes();
|
||||
if ($expireMinutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($round->status, ['pending', 'active'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $round->started_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $round->started_at->copy()->addMinutes($expireMinutes)->lte(now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:结算并结束已过期的回合,必要时发送超时公告。
|
||||
*/
|
||||
public function expireRound(IdiomGameRound $round, bool $announce = true): bool
|
||||
{
|
||||
if (! $this->isRoundExpired($round)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$round->loadMissing('idiom');
|
||||
|
||||
// 已过期的回合统一落为 ended,防止继续答题或阻塞新开题。
|
||||
$round->update([
|
||||
'status' => 'ended',
|
||||
'ended_at' => $round->ended_at ?? now(),
|
||||
]);
|
||||
|
||||
if ($announce) {
|
||||
$this->pushExpiredRoundMessage($round);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:批量清理指定房间内已超时但仍处于进行中的回合。
|
||||
*/
|
||||
public function expireActiveRoundsForRoom(int $roomId, bool $announce = true): int
|
||||
{
|
||||
$expiredCount = 0;
|
||||
|
||||
IdiomGameRound::with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (IdiomGameRound $round) use ($announce, &$expiredCount): void {
|
||||
if ($this->expireRound($round, $announce)) {
|
||||
$expiredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:向公屏推送猜成语超时公告。
|
||||
*/
|
||||
public function pushExpiredRoundMessage(IdiomGameRound $round): void
|
||||
{
|
||||
$answer = $round->idiom?->answer ?? '未知答案';
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($round->room_id),
|
||||
'room_id' => $round->room_id,
|
||||
'from_user' => '星海小博士',
|
||||
'to_user' => '大家',
|
||||
'content' => "⌛ 本轮猜成语已超时结束,正确答案是「{$answer}」。",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#f59e0b',
|
||||
'action' => '',
|
||||
'idiom_game_round_ended_id' => $round->id,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($round->room_id, $message);
|
||||
broadcast(new MessageSent($round->room_id, $message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动回合服务
|
||||
*
|
||||
* 统一处理题型兼容、房间范围、自动出题、超时结算与公屏公告,
|
||||
* 避免控制器与定时任务各自维护一套猜谜活动逻辑。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\RiddleGameStarted;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Riddle;
|
||||
use App\Models\RiddleGameRound;
|
||||
use App\Models\Room;
|
||||
|
||||
/**
|
||||
* 类功能:提供猜谜活动的配置读取、出题、过期结算与公告能力。
|
||||
*/
|
||||
class RiddleGameService
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:读取指定题型的完整配置,并兼容旧版平铺参数。
|
||||
*
|
||||
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
|
||||
*/
|
||||
public function getTypeConfig(?string $quizType = null): array
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
$config = GameConfig::forGame(Riddle::TYPE_IDIOM) ?? GameConfig::forGame($normalizedQuizType);
|
||||
$params = $config?->params ?? [];
|
||||
$typeConfig = (array) (($params['type_configs'] ?? [])[$normalizedQuizType] ?? []);
|
||||
$sharedRoomIds = $this->normalizeRoomIds(
|
||||
$params['room_ids']
|
||||
?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : [])
|
||||
);
|
||||
$roomMode = (string) ($params['room_scope_mode'] ?? ($typeConfig['room_mode'] ?? 'single'));
|
||||
|
||||
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
|
||||
$roomMode = 'single';
|
||||
}
|
||||
|
||||
$roomIds = $sharedRoomIds !== []
|
||||
? $sharedRoomIds
|
||||
: $this->normalizeRoomIds($typeConfig['room_ids'] ?? [1]);
|
||||
|
||||
return [
|
||||
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($typeConfig['reward_gold'] ?? 50))),
|
||||
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($typeConfig['reward_exp'] ?? 30))),
|
||||
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($typeConfig['expire_minutes'] ?? 5))),
|
||||
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($typeConfig['auto_start_interval'] ?? 0))),
|
||||
'room_mode' => $roomMode,
|
||||
'room_ids' => $roomIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取题目有效时长配置,单位分钟。
|
||||
*/
|
||||
public function getExpireMinutes(?string $quizType = null): int
|
||||
{
|
||||
return $this->getTypeConfig($quizType)['expire_minutes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取自动出题间隔配置,单位分钟。
|
||||
*/
|
||||
public function getAutoStartInterval(?string $quizType = null): int
|
||||
{
|
||||
return $this->getTypeConfig($quizType)['auto_start_interval'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取答题奖励配置。
|
||||
*
|
||||
* @return array{reward_gold:int,reward_exp:int}
|
||||
*/
|
||||
public function getRewardConfig(?string $quizType = null): array
|
||||
{
|
||||
$typeConfig = $this->getTypeConfig($quizType);
|
||||
|
||||
return [
|
||||
'reward_gold' => $typeConfig['reward_gold'],
|
||||
'reward_exp' => $typeConfig['reward_exp'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:将外部传入的题型归一化为系统支持值。
|
||||
*/
|
||||
public function normalizeQuizType(?string $quizType): string
|
||||
{
|
||||
$normalizedType = trim((string) $quizType);
|
||||
|
||||
return Riddle::isSupportedType($normalizedType)
|
||||
? $normalizedType
|
||||
: Riddle::TYPE_IDIOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回题型对应的中文名称。
|
||||
*/
|
||||
public function getQuizTypeLabel(string $quizType): string
|
||||
{
|
||||
return Riddle::labelForType($this->normalizeQuizType($quizType));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取自动出题的房间范围模式。
|
||||
*/
|
||||
public function getRoomScopeMode(?string $quizType = null): string
|
||||
{
|
||||
return $this->getTypeConfig($quizType)['room_mode'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取自动出题允许覆盖的房间列表。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function getScopedRoomIds(?string $quizType = null): array
|
||||
{
|
||||
$typeConfig = $this->getTypeConfig($quizType);
|
||||
$mode = $typeConfig['room_mode'];
|
||||
$configuredRoomIds = $typeConfig['room_ids'];
|
||||
|
||||
if ($mode === 'all') {
|
||||
return Room::query()->orderBy('id')->pluck('id')->map(fn (mixed $id): int => (int) $id)->all();
|
||||
}
|
||||
|
||||
if ($mode === 'single') {
|
||||
return array_slice($configuredRoomIds !== [] ? $configuredRoomIds : [1], 0, 1);
|
||||
}
|
||||
|
||||
return $configuredRoomIds !== [] ? $configuredRoomIds : [1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断指定回合是否已经超过有效时长。
|
||||
*/
|
||||
public function isRoundExpired(RiddleGameRound $round): bool
|
||||
{
|
||||
$expireMinutes = $this->getExpireMinutes($round->quiz_type);
|
||||
if ($expireMinutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($round->status, ['pending', 'active'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $round->started_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $round->started_at->copy()->addMinutes($expireMinutes)->lte(now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:结算并结束已过期的回合,必要时发送超时公告。
|
||||
*/
|
||||
public function expireRound(RiddleGameRound $round, bool $announce = true): bool
|
||||
{
|
||||
if (! $this->isRoundExpired($round)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$round->loadMissing('idiom');
|
||||
|
||||
// 已过期回合统一落为 ended,防止继续答题或阻塞新开题。
|
||||
$round->update([
|
||||
'status' => 'ended',
|
||||
'ended_at' => $round->ended_at ?? now(),
|
||||
]);
|
||||
|
||||
if ($announce) {
|
||||
$this->pushExpiredRoundMessage($round);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:批量清理指定房间内已超时但仍处于进行中的回合。
|
||||
*/
|
||||
public function expireActiveRoundsForRoom(int $roomId, bool $announce = true, ?string $quizType = null): int
|
||||
{
|
||||
$expiredCount = 0;
|
||||
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
|
||||
|
||||
RiddleGameRound::with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->when(
|
||||
$normalizedQuizType !== null,
|
||||
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
|
||||
)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (RiddleGameRound $round) use ($announce, &$expiredCount): void {
|
||||
if ($this->expireRound($round, $announce)) {
|
||||
$expiredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:手动结束指定房间指定题型的所有进行中回合。
|
||||
*/
|
||||
public function endActiveRoundsForRoom(int $roomId, ?string $quizType = null): int
|
||||
{
|
||||
$endedCount = 0;
|
||||
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
|
||||
|
||||
RiddleGameRound::query()
|
||||
->where('room_id', $roomId)
|
||||
->when(
|
||||
$normalizedQuizType !== null,
|
||||
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
|
||||
)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (RiddleGameRound $round) use (&$endedCount): void {
|
||||
// 手动出题覆盖旧题时,直接结束旧回合,不再额外发超时公告。
|
||||
$round->update([
|
||||
'status' => 'ended',
|
||||
'ended_at' => $round->ended_at ?? now(),
|
||||
]);
|
||||
$endedCount++;
|
||||
});
|
||||
|
||||
return $endedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:为指定房间和题型创建一轮新题。
|
||||
*/
|
||||
public function startRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
|
||||
if (! $this->isGameEnabled($normalizedQuizType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 先清理同房间同题型的过期回合,避免旧记录卡住新题。
|
||||
$this->expireActiveRoundsForRoom($roomId, true, $normalizedQuizType);
|
||||
|
||||
if ($this->findActiveRound($roomId, $normalizedQuizType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$idiom = $this->pickRandomQuestion($normalizedQuizType);
|
||||
if (! $idiom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rewardConfig = $this->getRewardConfig($normalizedQuizType);
|
||||
|
||||
// 新回合显式记录 quiz_type,保证房间与题型维度都能独立判定。
|
||||
$round = RiddleGameRound::create([
|
||||
'room_id' => $roomId,
|
||||
'idiom_id' => $idiom->id,
|
||||
'quiz_type' => $normalizedQuizType,
|
||||
'status' => 'active',
|
||||
'reward_gold' => $rewardConfig['reward_gold'],
|
||||
'reward_exp' => $rewardConfig['reward_exp'],
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$round->setRelation('idiom', $idiom);
|
||||
$this->broadcastStartedRound($round);
|
||||
|
||||
return $round;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:按配置范围自动为各房间各题型尝试开题。
|
||||
*/
|
||||
public function autoStartEligibleRounds(): int
|
||||
{
|
||||
$startedCount = 0;
|
||||
|
||||
foreach (Riddle::supportedTypes() as $quizType) {
|
||||
$interval = $this->getAutoStartInterval($quizType);
|
||||
if ($interval <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->getScopedRoomIds($quizType) as $roomId) {
|
||||
// 房间与题型维度独立结算过期回合,互不干扰。
|
||||
$this->expireActiveRoundsForRoom($roomId, true, $quizType);
|
||||
|
||||
if ($this->findActiveRound($roomId, $quizType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->hasReachedAutoStartInterval($roomId, $quizType, $interval)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->pickRandomQuestion($quizType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->startRound($roomId, $quizType)) {
|
||||
$startedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $startedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询指定房间指定题型的进行中回合。
|
||||
*/
|
||||
public function findActiveRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
|
||||
{
|
||||
return RiddleGameRound::query()
|
||||
->with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->where('quiz_type', $this->normalizeQuizType($quizType))
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:随机抽取一条启用中的题目。
|
||||
*/
|
||||
public function pickRandomQuestion(?string $quizType = null): ?Riddle
|
||||
{
|
||||
return Riddle::query()
|
||||
->where('type', $this->normalizeQuizType($quizType))
|
||||
->where('is_active', true)
|
||||
->inRandomOrder()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:生成答题奖励日志文案。
|
||||
*/
|
||||
public function buildRewardDescription(RiddleGameRound $round): string
|
||||
{
|
||||
$quizTypeLabel = $this->getQuizTypeLabel($round->quiz_type);
|
||||
|
||||
return "猜谜活动{$quizTypeLabel}答对「{$round->idiom?->answer}」奖励";
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:向公屏推送回合超时公告。
|
||||
*/
|
||||
public function pushExpiredRoundMessage(RiddleGameRound $round): void
|
||||
{
|
||||
$answer = $round->idiom?->answer ?? '未知答案';
|
||||
$quizTitle = Riddle::activityLabelForType($round->quiz_type);
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($round->room_id),
|
||||
'room_id' => $round->room_id,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "⏳ 【{$quizTitle}】第 #{$round->id} 题已超时结束!正确答案:{$answer}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '',
|
||||
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
|
||||
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
|
||||
'quiz_round_id' => $round->id,
|
||||
'quiz_round_ended_id' => $round->id,
|
||||
'quiz_answer' => $answer,
|
||||
'idiom_game_round_ended_id' => $round->id,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($round->room_id, $message);
|
||||
broadcast(new MessageSent($round->room_id, $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:广播新回合开始事件并同步写入公屏消息。
|
||||
*/
|
||||
public function broadcastStartedRound(RiddleGameRound $round): void
|
||||
{
|
||||
$round->loadMissing('idiom');
|
||||
|
||||
broadcast(new RiddleGameStarted(
|
||||
roomId: $round->room_id,
|
||||
quizType: $round->quiz_type,
|
||||
hint: $round->idiom?->hint ?? '',
|
||||
roundId: $round->id,
|
||||
rewardGold: $round->reward_gold,
|
||||
rewardExp: $round->reward_exp,
|
||||
));
|
||||
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($round->room_id),
|
||||
'room_id' => $round->room_id,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $this->buildStartMessage($round->quiz_type, $round->id, $round->idiom?->hint ?? ''),
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
|
||||
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
|
||||
'quiz_round_id' => $round->id,
|
||||
'quiz_hint' => $round->idiom?->hint ?? '',
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
'idiom_game_round_id' => $round->id,
|
||||
'idiom_reward_gold' => $round->reward_gold,
|
||||
'idiom_reward_exp' => $round->reward_exp,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($round->room_id, $message);
|
||||
broadcast(new MessageSent($round->room_id, $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断指定房间指定题型是否已到自动开题间隔。
|
||||
*/
|
||||
private function hasReachedAutoStartInterval(int $roomId, string $quizType, int $interval): bool
|
||||
{
|
||||
$lastRound = RiddleGameRound::query()
|
||||
->where('room_id', $roomId)
|
||||
->where('quiz_type', $this->normalizeQuizType($quizType))
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $lastRound) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$lastTime = $lastRound->ended_at ?? $lastRound->started_at ?? $lastRound->created_at;
|
||||
|
||||
return ! $lastTime || $lastTime->diffInMinutes(now()) >= $interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:把 room_ids 配置归一化为整型数组。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeRoomIds(mixed $roomIds): array
|
||||
{
|
||||
$items = is_array($roomIds)
|
||||
? $roomIds
|
||||
: preg_split('/[\s,,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
return collect($items)
|
||||
->map(fn (mixed $roomId): int => (int) $roomId)
|
||||
->filter(fn (int $roomId): bool => $roomId > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回题型对应的活动标题。
|
||||
*/
|
||||
private function buildStartMessage(string $quizType, int $roundId, string $hint): string
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
$quizLabel = $this->getQuizTypeLabel($normalizedQuizType);
|
||||
$icon = $normalizedQuizType === Riddle::TYPE_BRAIN_TEASER ? '🧠' : '🧩';
|
||||
|
||||
return "{$icon} 【猜谜活动·{$quizLabel}】第 #{$roundId} 题开始!题面:{$hint}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断猜谜活动总开关是否处于启用状态。
|
||||
*/
|
||||
private function isGameEnabled(?string $quizType = null): bool
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
$config = GameConfig::forGame($normalizedQuizType) ?? GameConfig::forGame(Riddle::TYPE_IDIOM);
|
||||
|
||||
return (bool) $config?->enabled;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:将猜成语数据结构升级为猜谜活动通用结构
|
||||
*
|
||||
* 为题库增加题型字段,为回合增加 quiz_type 与复合索引,
|
||||
* 兼容既有“猜成语”数据并为脑筋急转弯题型预留能力。
|
||||
*/
|
||||
|
||||
use App\Models\Riddle;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:执行表结构升级并补齐历史数据默认值。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('idioms', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('idioms', 'type')) {
|
||||
$table->string('type', 30)->default(Riddle::TYPE_IDIOM)->after('id')->comment('题型:idiom/brain_teaser');
|
||||
}
|
||||
});
|
||||
|
||||
// 历史成语题默认归类到 idiom,保证旧数据无需人工修复。
|
||||
DB::table('idioms')
|
||||
->whereNull('type')
|
||||
->orWhere('type', '')
|
||||
->update(['type' => Riddle::TYPE_IDIOM]);
|
||||
|
||||
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('idiom_game_rounds', 'quiz_type')) {
|
||||
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM)->after('idiom_id')->comment('回合题型:idiom/brain_teaser');
|
||||
}
|
||||
});
|
||||
|
||||
// 历史回合默认按成语题处理,确保旧记录仍可正常展示与过期结算。
|
||||
DB::table('idiom_game_rounds')
|
||||
->whereNull('quiz_type')
|
||||
->orWhere('quiz_type', '')
|
||||
->update(['quiz_type' => Riddle::TYPE_IDIOM]);
|
||||
|
||||
Schema::table('idioms', function (Blueprint $table): void {
|
||||
$table->index(['type', 'is_active'], 'idioms_type_is_active_index');
|
||||
});
|
||||
|
||||
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
|
||||
$table->index(['room_id', 'quiz_type', 'status'], 'idiom_rounds_room_type_status_index');
|
||||
$table->index(['room_id', 'quiz_type', 'id'], 'idiom_rounds_room_type_id_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚猜谜活动通用结构升级。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
|
||||
$table->dropIndex('idiom_rounds_room_type_status_index');
|
||||
$table->dropIndex('idiom_rounds_room_type_id_index');
|
||||
$table->dropColumn('quiz_type');
|
||||
});
|
||||
|
||||
Schema::table('idioms', function (Blueprint $table): void {
|
||||
$table->dropIndex('idioms_type_is_active_index');
|
||||
$table->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语题库填充器
|
||||
* 文件功能:猜谜活动题库填充器
|
||||
*
|
||||
* 初始化游戏配置和成语题库数据。
|
||||
* 初始化猜谜活动配置、成语题库与脑筋急转弯题库数据。
|
||||
* 使用 updateOrCreate 确保重复执行不影响已有数据。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
@@ -14,29 +14,52 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Idiom;
|
||||
use App\Models\Riddle;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class IdiomSeeder extends Seeder
|
||||
/**
|
||||
* 类功能:初始化猜谜活动配置、成语题库与脑筋急转弯题库。
|
||||
*/
|
||||
class RiddleSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* 填充猜成语游戏配置和题库。
|
||||
* 填充猜谜活动配置与题库。
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// ── 游戏配置(已存在则跳过) ──
|
||||
// ── 游戏配置(已存在则更新为统一猜谜活动结构) ──
|
||||
GameConfig::updateOrCreate(
|
||||
['game_key' => 'idiom'],
|
||||
[
|
||||
'name' => '猜成语',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'description' => '管理员手动出题或系统定时自动出题,用户抢答成语,第一个答对的获得金币和经验奖励。',
|
||||
'description' => '管理员手动出题或系统定时自动出题,支持成语题与脑筋急转弯题,第一个答对的用户获得金币和经验奖励。',
|
||||
'enabled' => false,
|
||||
'params' => [
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [1],
|
||||
'type_configs' => [
|
||||
Riddle::TYPE_IDIOM => [
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_mode' => 'single',
|
||||
'room_ids' => [1],
|
||||
],
|
||||
Riddle::TYPE_BRAIN_TEASER => [
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_mode' => 'single',
|
||||
'room_ids' => [1],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -147,13 +170,134 @@ class IdiomSeeder extends Seeder
|
||||
['answer' => '愚公移山', 'hint' => '🧩 九十岁老头发誓要搬走门口的两座大山,子子孙孙无穷匮也。猜一成语'],
|
||||
];
|
||||
|
||||
foreach ($idioms as $idiom) {
|
||||
Idiom::updateOrCreate(
|
||||
['answer' => $idiom['answer']],
|
||||
foreach ($idioms as $index => $idiom) {
|
||||
Riddle::updateOrCreate(
|
||||
[
|
||||
'type' => Riddle::TYPE_IDIOM,
|
||||
'answer' => $idiom['answer'],
|
||||
],
|
||||
[
|
||||
'hint' => $idiom['hint'],
|
||||
'is_active' => true,
|
||||
'sort' => 0,
|
||||
'sort' => $index + 1,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 新增脑筋急转弯题库,供猜谜活动切换题型时直接使用。
|
||||
$brainTeasers = [
|
||||
['answer' => '影子', 'hint' => '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?'],
|
||||
['answer' => '回声', 'hint' => '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?'],
|
||||
['answer' => '镜子', 'hint' => '🧠 你哭它也哭,你笑它也笑,但它永远不会先动,是什么?'],
|
||||
['answer' => '口罩', 'hint' => '🧠 戴在脸上不是面具,遮住口鼻保健康,是什么?'],
|
||||
['answer' => '手套', 'hint' => '🧠 五个小兄弟住两套房,冬天最爱穿,是什么?'],
|
||||
['answer' => '袜子', 'hint' => '🧠 一对好朋友,天天躲鞋里,是什么?'],
|
||||
['answer' => '鞋带', 'hint' => '🧠 两条细长蛇,天天趴鞋上,不打结鞋就跑,是什么?'],
|
||||
['answer' => '雨伞', 'hint' => '🧠 下雨天开花,晴天就收起,是什么?'],
|
||||
['answer' => '帽子', 'hint' => '🧠 不长头发却总爱站在头顶,是什么?'],
|
||||
['answer' => '围巾', 'hint' => '🧠 冬天挂脖子,既不是项链也不是绳子,是什么?'],
|
||||
['answer' => '口红', 'hint' => '🧠 不是彩笔,却常在嘴上画颜色,是什么?'],
|
||||
['answer' => '牙刷', 'hint' => '🧠 头上长毛,天天进嘴里干活,是什么?'],
|
||||
['answer' => '牙膏', 'hint' => '🧠 白白一条小胖虫,挤出来给牙刷帮忙,是什么?'],
|
||||
['answer' => '肥皂', 'hint' => '🧠 越洗越瘦,越搓泡泡越多,是什么?'],
|
||||
['answer' => '毛巾', 'hint' => '🧠 洗完脸后最爱找它抱一抱,是什么?'],
|
||||
['answer' => '梳子', 'hint' => '🧠 一排小牙齿,不吃饭,只理头发,是什么?'],
|
||||
['answer' => '吹风机', 'hint' => '🧠 会吹热风的小机器,洗完头总请它帮忙,是什么?'],
|
||||
['answer' => '指甲刀', 'hint' => '🧠 身子很小嘴巴很硬,专门啃手指头,是什么?'],
|
||||
['answer' => '钥匙', 'hint' => '🧠 个子不大本事大,能把锁头嘴巴打开,是什么?'],
|
||||
['answer' => '锁', 'hint' => '🧠 一张铁嘴不吃饭,钥匙一来才张口,是什么?'],
|
||||
['answer' => '门铃', 'hint' => '🧠 客人到门口,不敲门先叫唤,是什么?'],
|
||||
['answer' => '电梯', 'hint' => '🧠 关上门就上下跑,不是汽车不上路,是什么?'],
|
||||
['answer' => '楼梯', 'hint' => '🧠 一节一节往上走,不会动却能送人上楼,是什么?'],
|
||||
['answer' => '窗户', 'hint' => '🧠 墙上开个洞,白天爱看风景,晚上爱看月亮,是什么?'],
|
||||
['answer' => '窗帘', 'hint' => '🧠 白天拉开,晚上关上,帮房间遮眼睛,是什么?'],
|
||||
['answer' => '镜框', 'hint' => '🧠 不会照人,却总抱着照片或镜子,是什么?'],
|
||||
['answer' => '桌子', 'hint' => '🧠 四条腿不会走,肚皮平平能放东西,是什么?'],
|
||||
['answer' => '椅子', 'hint' => '🧠 有脚不走路,专门让人坐,是什么?'],
|
||||
['answer' => '沙发', 'hint' => '🧠 胖胖软软客厅王,大家累了都爱躺,是什么?'],
|
||||
['answer' => '床', 'hint' => '🧠 白天安安静静,晚上最忙,是什么?'],
|
||||
['answer' => '枕头', 'hint' => '🧠 软绵绵的小山包,睡觉时总垫在头下,是什么?'],
|
||||
['answer' => '被子', 'hint' => '🧠 白天叠成豆腐块,晚上张开抱住你,是什么?'],
|
||||
['answer' => '闹钟', 'hint' => '🧠 不会说早安,却总把你从梦里拽出来,是什么?'],
|
||||
['answer' => '日历', 'hint' => '🧠 每过一天就瘦一张,是什么?'],
|
||||
['answer' => '时钟', 'hint' => '🧠 三根兄弟赛跑,一圈一圈不停歇,是什么?'],
|
||||
['answer' => '手机', 'hint' => '🧠 不长嘴巴却能说话,不长耳朵却能听见,是什么?'],
|
||||
['answer' => '电话', 'hint' => '🧠 两地相隔很远,也能贴耳说悄悄话,是什么?'],
|
||||
['answer' => '电视', 'hint' => '🧠 小小方盒子,里面天天演大戏,是什么?'],
|
||||
['answer' => '遥控器', 'hint' => '🧠 不用走过去,按按它就能让电视听话,是什么?'],
|
||||
['answer' => '电脑', 'hint' => '🧠 肚子里装知识,手指一敲就干活,是什么?'],
|
||||
['answer' => '键盘', 'hint' => '🧠 一排排小方块,不是钢琴也能打字,是什么?'],
|
||||
['answer' => '鼠标', 'hint' => '🧠 名字像老鼠,却最怕猫,天天趴桌上,是什么?'],
|
||||
['answer' => '耳机', 'hint' => '🧠 一左一右挂耳边,音乐只给你一个人听,是什么?'],
|
||||
['answer' => '音箱', 'hint' => '🧠 肚里藏着喇叭,最会放大声音,是什么?'],
|
||||
['answer' => '充电器', 'hint' => '🧠 手机饿了它喂饭,是什么?'],
|
||||
['answer' => '电池', 'hint' => '🧠 个子小,电量大,很多机器靠它活,是什么?'],
|
||||
['answer' => '电灯', 'hint' => '🧠 太阳下班它上岗,是什么?'],
|
||||
['answer' => '灯泡', 'hint' => '🧠 玻璃肚里藏火苗,黑夜一亮像白天,是什么?'],
|
||||
['answer' => '蜡烛', 'hint' => '🧠 有泪不会哭,有火不会叫,越烧越短,是什么?'],
|
||||
['answer' => '火柴', 'hint' => '🧠 头戴红帽子,脾气特别火,一擦就冒火星,是什么?'],
|
||||
['answer' => '打火机', 'hint' => '🧠 小盒子脾气爆,拇指一按就出火,是什么?'],
|
||||
['answer' => '冰箱', 'hint' => '🧠 肚子大又冷,专门帮食物避暑,是什么?'],
|
||||
['answer' => '空调', 'hint' => '🧠 夏天送凉风,冬天送暖风,挂在墙上最勤快,是什么?'],
|
||||
['answer' => '风扇', 'hint' => '🧠 没有翅膀也会转,专给人送风,是什么?'],
|
||||
['answer' => '洗衣机', 'hint' => '🧠 不长手,却特别会洗衣服,是什么?'],
|
||||
['answer' => '熨斗', 'hint' => '🧠 衣服皱巴巴,它一来就服服帖帖,是什么?'],
|
||||
['answer' => '微波炉', 'hint' => '🧠 剩饭剩菜进去转几圈,就又热乎了,是什么?'],
|
||||
['answer' => '电饭煲', 'hint' => '🧠 白米进去,香饭出来,是什么?'],
|
||||
['answer' => '锅', 'hint' => '🧠 黑脸大肚子,天天在灶台上唱歌,是什么?'],
|
||||
['answer' => '筷子', 'hint' => '🧠 两个瘦兄弟,合作夹饭菜,是什么?'],
|
||||
['answer' => '勺子', 'hint' => '🧠 有个圆脑袋,喝汤最拿手,是什么?'],
|
||||
['answer' => '碗', 'hint' => '🧠 圆圆小肚皮,最爱装米饭和汤,是什么?'],
|
||||
['answer' => '盘子', 'hint' => '🧠 扁扁一张脸,端菜最稳,是什么?'],
|
||||
['answer' => '杯子', 'hint' => '🧠 不会说话却总装水,是什么?'],
|
||||
['answer' => '水壶', 'hint' => '🧠 肚子能装水,嘴巴细细长长,是什么?'],
|
||||
['answer' => '吸管', 'hint' => '🧠 不用嘴碰杯,也能把饮料送进肚,是什么?'],
|
||||
['answer' => '铅笔', 'hint' => '🧠 身子细细穿木衣,肚里黑黑会写字,是什么?'],
|
||||
['answer' => '橡皮', 'hint' => '🧠 不会写字专会擦,哪里写错它就上,是什么?'],
|
||||
['answer' => '尺子', 'hint' => '🧠 身子直直会量长短,还能帮人画直线,是什么?'],
|
||||
['answer' => '书包', 'hint' => '🧠 不会走路却天天背着书上学,是什么?'],
|
||||
['answer' => '课本', 'hint' => '🧠 不会说话却肚子里全是知识,是什么?'],
|
||||
['answer' => '黑板', 'hint' => '🧠 一张大黑脸,粉笔天天在上面写字,是什么?'],
|
||||
['answer' => '粉笔', 'hint' => '🧠 白白瘦瘦,最爱在黑板上留下痕迹,是什么?'],
|
||||
['answer' => '粉笔擦', 'hint' => '🧠 不会写字却专门抹掉黑板上的字,是什么?'],
|
||||
['answer' => '书签', 'hint' => '🧠 个子小小住书里,帮你记住看到哪一页,是什么?'],
|
||||
['answer' => '放大镜', 'hint' => '🧠 小东西一到它眼前,立刻显得很大,是什么?'],
|
||||
['answer' => '望远镜', 'hint' => '🧠 明明站在原地,却能看见很远的东西,是什么?'],
|
||||
['answer' => '相机', 'hint' => '🧠 不会画画却能把风景留住,是什么?'],
|
||||
['answer' => '照片', 'hint' => '🧠 不会动不会说,却能把昨天留下来,是什么?'],
|
||||
['answer' => '地图', 'hint' => '🧠 不出门也能带你认识世界,是什么?'],
|
||||
['answer' => '地球仪', 'hint' => '🧠 一个蓝色大圆球,抱着全世界,是什么?'],
|
||||
['answer' => '篮球', 'hint' => '🧠 穿着橙色外衣,最爱往框里钻,是什么?'],
|
||||
['answer' => '足球', 'hint' => '🧠 用脚最喜欢的圆朋友,是什么?'],
|
||||
['answer' => '羽毛球', 'hint' => '🧠 头圆圆,尾巴白白,飞起来像小鸟,是什么?'],
|
||||
['answer' => '乒乓球', 'hint' => '🧠 白白小圆豆,桌上跳来跳去,是什么?'],
|
||||
['answer' => '跳绳', 'hint' => '🧠 一根长线会转圈,小朋友最爱跳过去,是什么?'],
|
||||
['answer' => '秋千', 'hint' => '🧠 坐上去前后飞,却飞不离原地,是什么?'],
|
||||
['answer' => '风筝', 'hint' => '🧠 长着尾巴在天上飞,线一松就跑,是什么?'],
|
||||
['answer' => '气球', 'hint' => '🧠 胖胖肚子装着气,手一松就想上天,是什么?'],
|
||||
['answer' => '雪人', 'hint' => '🧠 冬天站院里,太阳一晒就瘦,是什么?'],
|
||||
['answer' => '彩虹', 'hint' => '🧠 雨过天晴天上挂着七色桥,是什么?'],
|
||||
['answer' => '云', 'hint' => '🧠 白天像棉花,风一吹就变样,是什么?'],
|
||||
['answer' => '雾', 'hint' => '🧠 没下雨却湿漉漉,早晨最爱挡路,是什么?'],
|
||||
['answer' => '霜', 'hint' => '🧠 不是雪却白在草上,太阳一出就化,是什么?'],
|
||||
['answer' => '露珠', 'hint' => '🧠 清晨叶子上挂着一颗颗小珍珠,是什么?'],
|
||||
['answer' => '月亮', 'hint' => '🧠 白天看不清,晚上天上挂银盘,是什么?'],
|
||||
['answer' => '星星', 'hint' => '🧠 白天藏起来,晚上眨眼睛,是什么?'],
|
||||
['answer' => '太阳', 'hint' => '🧠 白天值班最勤快,大家都靠它发光发热,是什么?'],
|
||||
['answer' => '雷', 'hint' => '🧠 看不见摸不着,却能在天上大声打鼓,是什么?'],
|
||||
['answer' => '闪电', 'hint' => '🧠 先看到一道亮鞭子,再听见轰隆隆,是什么?'],
|
||||
];
|
||||
|
||||
foreach ($brainTeasers as $index => $brainTeaser) {
|
||||
Riddle::updateOrCreate(
|
||||
[
|
||||
'type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
'answer' => $brainTeaser['answer'],
|
||||
],
|
||||
[
|
||||
'hint' => $brainTeaser['hint'],
|
||||
'is_active' => true,
|
||||
'sort' => 1000 + $index + 1,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -292,8 +292,8 @@ import { bindChatInitialStateControls } from "./chat-room/initial-state.js";
|
||||
import "./chat-room/pat.js";
|
||||
|
||||
// 猜成语游戏模块
|
||||
import "./chat-room/idiom-quiz.js";
|
||||
import { bindIdiomQuizControls } from "./chat-room/idiom-quiz.js";
|
||||
import "./chat-room/riddle-quiz.js";
|
||||
import { bindIdiomQuizControls } from "./chat-room/riddle-quiz.js";
|
||||
|
||||
// 斜杠命令菜单
|
||||
import { bindSlashCommands, registerSlashCommand } from "./chat-room/slash-commands.js";
|
||||
|
||||
@@ -504,15 +504,15 @@ export function bindChatEvents() {
|
||||
|
||||
// chat:idiom-started — 猜成语出题
|
||||
window.addEventListener("chat:idiom-started", (e) => {
|
||||
if (typeof window.handleIdiomGameStarted === "function") {
|
||||
window.handleIdiomGameStarted(e);
|
||||
if (typeof window.handleRiddleGameStarted === "function") {
|
||||
window.handleRiddleGameStarted(e);
|
||||
}
|
||||
});
|
||||
|
||||
// chat:idiom-answered — 猜成语答题结果
|
||||
window.addEventListener("chat:idiom-answered", (e) => {
|
||||
if (typeof window.handleIdiomGameAnswered === "function") {
|
||||
window.handleIdiomGameAnswered(e);
|
||||
if (typeof window.handleRiddleGameAnswered === "function") {
|
||||
window.handleRiddleGameAnswered(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
// 猜成语游戏前端模块
|
||||
// 监听 IdiomGameStarted / IdiomGameAnswered 事件,提供答题弹窗功能
|
||||
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
let currentRoundId = 0;
|
||||
let currentRoomId = 0;
|
||||
|
||||
/**
|
||||
* 查找当前回合是否已经有对应的聊天室消息节点。
|
||||
*/
|
||||
function findIdiomRoundMessageNode(roundId) {
|
||||
if (roundId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return document.querySelector(`[data-idiom-round-id="${roundId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定回合创建统一样式的答题按钮。
|
||||
*/
|
||||
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.idiomAnswerBtn = String(roundId);
|
||||
btn.dataset.idiomHint = hint;
|
||||
btn.dataset.idiomGold = String(rewardGold);
|
||||
btn.dataset.idiomExp = String(rewardExp);
|
||||
btn.textContent = "🎯 答题";
|
||||
btn.style.cssText =
|
||||
"margin-left:8px;padding:2px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
|
||||
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
|
||||
"font-weight:bold;vertical-align:middle;";
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定回合的所有答题按钮。
|
||||
*/
|
||||
export function removeIdiomAnswerButtons(roundId = 0) {
|
||||
const selector = roundId > 0
|
||||
? `[data-idiom-answer-btn="${roundId}"]`
|
||||
: "[data-idiom-answer-btn]";
|
||||
|
||||
document.querySelectorAll(selector).forEach((button) => button.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
|
||||
*/
|
||||
export function attachIdiomAnswerButton(messageNode, message) {
|
||||
if (!messageNode || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roundId = Number.parseInt(
|
||||
String(message.idiom_game_round_id || message.idom_game_round_id || "0"),
|
||||
10,
|
||||
);
|
||||
if (roundId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.parseInt(String(message.idiom_game_round_ended_id || "0"), 10) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.from_user !== "星海小博士") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageNode.querySelector(`[data-idiom-answer-btn="${roundId}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hint = String(message.content || "");
|
||||
const rewardGold = Number.parseInt(String(message.idiom_reward_gold || "0"), 10);
|
||||
const rewardExp = Number.parseInt(String(message.idiom_reward_exp || "0"), 10);
|
||||
const button = buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp);
|
||||
const timeNode = messageNode.querySelector(".msg-time");
|
||||
|
||||
if (timeNode?.parentNode) {
|
||||
timeNode.parentNode.insertBefore(button, timeNode.nextSibling);
|
||||
return;
|
||||
}
|
||||
|
||||
messageNode.appendChild(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
|
||||
*/
|
||||
async function syncCurrentIdiomRound() {
|
||||
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
|
||||
if (roomId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentRoomId = roomId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/idiom-quiz/current?room_id=${roomId}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const activeRoundId = Number.parseInt(String(data?.data?.round_id || "0"), 10);
|
||||
|
||||
currentRoundId = activeRoundId;
|
||||
|
||||
if (activeRoundId <= 0) {
|
||||
removeIdiomAnswerButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-idiom-answer-btn]").forEach((button) => {
|
||||
if (button.dataset.idiomAnswerBtn !== String(activeRoundId)) {
|
||||
button.remove();
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
|
||||
*/
|
||||
function handleIdiomGameStarted(e) {
|
||||
const { round_id, hint, reward_gold, reward_exp, message } = e.detail || {};
|
||||
if (!round_id || !hint) return;
|
||||
|
||||
currentRoundId = round_id;
|
||||
currentRoomId = window.chatContext?.roomId || 0;
|
||||
|
||||
// 线上如果 MessageSent 补消息没有到达,这里主动补一条公屏消息兜底;
|
||||
// 本地或正常链路下若消息已存在,则只补挂答题按钮,避免重复渲染。
|
||||
const existingMessageNode = findIdiomRoundMessageNode(round_id);
|
||||
if (existingMessageNode) {
|
||||
attachIdiomAnswerButton(existingMessageNode, {
|
||||
from_user: "星海小博士",
|
||||
content: message || `🧩 猜成语时间!${hint}`,
|
||||
idiom_game_round_id: round_id,
|
||||
idiom_reward_gold: reward_gold,
|
||||
idiom_reward_exp: reward_exp,
|
||||
});
|
||||
console.log(`猜成语开始:${hint},奖励 ${reward_gold}金/${reward_exp}经验`);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
window.appendMessage?.({
|
||||
id: `idiom-start-live-${round_id}`,
|
||||
room_id: currentRoomId || window.chatContext?.roomId || 0,
|
||||
from_user: "星海小博士",
|
||||
to_user: "大家",
|
||||
content: message || `🧩 猜成语时间!${hint}`,
|
||||
is_secret: false,
|
||||
font_color: "#7c3aed",
|
||||
action: "",
|
||||
idiom_game_round_id: round_id,
|
||||
idiom_reward_gold: reward_gold,
|
||||
idiom_reward_exp: reward_exp,
|
||||
sent_at: timeStr,
|
||||
});
|
||||
|
||||
console.log(`猜成语开始:${hint},奖励 ${reward_gold}金/${reward_exp}经验`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到猜成语结果事件。
|
||||
*/
|
||||
function handleIdiomGameAnswered(e) {
|
||||
const { answer, winner_username, reward_gold, reward_exp, round_id } = e.detail || {};
|
||||
if (!answer) return;
|
||||
|
||||
currentRoundId = 0;
|
||||
removeIdiomAnswerButtons(round_id);
|
||||
|
||||
// 关闭当前用户的答题弹窗(如果开着的话)
|
||||
const answerModal = document.getElementById("idiom-answer-modal");
|
||||
if (answerModal && answerModal.style.display !== "none") {
|
||||
answerModal.style.display = "none";
|
||||
}
|
||||
|
||||
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
window.appendMessage?.({
|
||||
id: `idiom-result-live-${round_id}-${Date.now()}`,
|
||||
room_id: currentRoomId || window.chatContext?.roomId || 0,
|
||||
from_user: "星海小博士",
|
||||
to_user: "大家",
|
||||
content: `🎉 【${winner_username}】率先答对成语「${answer}」,获得 ${reward_gold} 金币、${reward_exp} 经验!`,
|
||||
is_secret: false,
|
||||
font_color: "#16a34a",
|
||||
action: "idiom_result",
|
||||
winner_username,
|
||||
idiom_answer: answer,
|
||||
idiom_result_reward_gold: reward_gold,
|
||||
idiom_result_reward_exp: reward_exp,
|
||||
idiom_game_round_ended_id: round_id,
|
||||
sent_at: timeStr,
|
||||
});
|
||||
|
||||
// ── Toast 通知(所有用户都能看到) ──
|
||||
window.chatToast?.show({
|
||||
title: "🧩 猜成语",
|
||||
message: `<b>${winner_username}</b> 答对了「${answer}」,获得 ${reward_gold}💰 + ${reward_exp}⭐!`,
|
||||
icon: "🎉",
|
||||
color: "#16a34a",
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开答题弹窗。
|
||||
*/
|
||||
function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp) {
|
||||
currentRoundId = roundId;
|
||||
currentRoomId = window.chatContext?.roomId || 0;
|
||||
|
||||
const modal = document.getElementById("idiom-answer-modal");
|
||||
if (!modal) return;
|
||||
|
||||
const hintEl = document.getElementById("idiom-answer-hint");
|
||||
const rewardEl = document.getElementById("idiom-answer-reward");
|
||||
if (hintEl) hintEl.textContent = hint;
|
||||
if (rewardEl) rewardEl.textContent = `🎁 答对奖励:${rewardGold} 金币 + ${rewardExp} 经验`;
|
||||
|
||||
modal.style.display = "flex";
|
||||
|
||||
const input = document.getElementById("idiom-answer-input");
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.focus();
|
||||
input.disabled = false;
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById("idiom-answer-submit");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交答案";
|
||||
}
|
||||
|
||||
const feedbackEl = document.getElementById("idiom-answer-feedback");
|
||||
if (feedbackEl) feedbackEl.textContent = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭答题弹窗。
|
||||
*/
|
||||
function closeIdiomAnswerModal() {
|
||||
const modal = document.getElementById("idiom-answer-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交答案。
|
||||
*/
|
||||
async function submitIdiomAnswer() {
|
||||
const input = document.getElementById("idiom-answer-input");
|
||||
const feedbackEl = document.getElementById("idiom-answer-feedback");
|
||||
const submitBtn = document.getElementById("idiom-answer-submit");
|
||||
|
||||
if (!input || !feedbackEl || !submitBtn) return;
|
||||
|
||||
const answer = input.value.trim();
|
||||
if (!answer) {
|
||||
feedbackEl.textContent = "请输入成语答案";
|
||||
feedbackEl.style.color = "#ef4444";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "提交中...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/idiom-quiz/answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
round_id: currentRoundId,
|
||||
answer: answer,
|
||||
room_id: currentRoomId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
feedbackEl.textContent = data.message || "🎉 回答正确!";
|
||||
feedbackEl.style.color = "#16a34a";
|
||||
input.disabled = true;
|
||||
|
||||
// 延迟关闭弹窗
|
||||
setTimeout(() => {
|
||||
closeIdiomAnswerModal();
|
||||
}, 2000);
|
||||
} else {
|
||||
feedbackEl.textContent = data.message || "答案不正确";
|
||||
feedbackEl.style.color = "#ef4444";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交答案";
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackEl.textContent = "网络错误,请稍后重试";
|
||||
feedbackEl.style.color = "#ef4444";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交答案";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 事件绑定 ──
|
||||
|
||||
export function bindIdiomQuizControls() {
|
||||
// 已经绑定的不再重复绑定
|
||||
if (document.getElementById("idiom-answer-modal")?.dataset?.idiomBound) return;
|
||||
const modal = document.getElementById("idiom-answer-modal");
|
||||
if (modal) modal.dataset.idiomBound = "1";
|
||||
|
||||
// 关闭按钮
|
||||
document.addEventListener("click", (e) => {
|
||||
const closeBtn = e.target.closest("[data-idiom-answer-close]");
|
||||
if (closeBtn) {
|
||||
closeIdiomAnswerModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击遮罩层关闭
|
||||
const overlay = e.target.closest("#idiom-answer-modal");
|
||||
if (overlay && e.target === overlay) {
|
||||
closeIdiomAnswerModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 提交按钮
|
||||
document.addEventListener("click", (e) => {
|
||||
const submitBtn = e.target.closest("[data-idiom-answer-submit]");
|
||||
if (submitBtn) {
|
||||
e.preventDefault();
|
||||
submitIdiomAnswer();
|
||||
}
|
||||
});
|
||||
|
||||
// 输入框 Enter 提交
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const input = e.target.closest("#idiom-answer-input");
|
||||
if (input && e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitIdiomAnswer();
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天消息中的【答题】按钮点击
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-idiom-answer-btn]");
|
||||
if (!btn) return;
|
||||
|
||||
const roundId = parseInt(btn.dataset.idiomAnswerBtn || "0", 10);
|
||||
const hint = btn.dataset.idiomHint || "";
|
||||
const rewardGold = parseInt(btn.dataset.idiomGold || "0", 10);
|
||||
const rewardExp = parseInt(btn.dataset.idiomExp || "0", 10);
|
||||
|
||||
if (roundId > 0) {
|
||||
openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
|
||||
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
|
||||
|
||||
window.setTimeout(() => {
|
||||
syncCurrentIdiomRound();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ── 挂载到 window ──
|
||||
window.openIdiomAnswerModal = openIdiomAnswerModal;
|
||||
window.closeIdiomAnswerModal = closeIdiomAnswerModal;
|
||||
window.submitIdiomAnswer = submitIdiomAnswer;
|
||||
window.handleIdiomGameStarted = handleIdiomGameStarted;
|
||||
window.handleIdiomGameAnswered = handleIdiomGameAnswered;
|
||||
@@ -2,7 +2,13 @@
|
||||
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
|
||||
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
||||
import { attachIdiomAnswerButton, removeIdiomAnswerButtons } from "./idiom-quiz.js";
|
||||
import {
|
||||
attachIdiomAnswerButton,
|
||||
buildQuizActivityTitle,
|
||||
disableIdiomAnswerButtons,
|
||||
isQuizStartMessage,
|
||||
normalizeQuizRoundPayload,
|
||||
} from "./riddle-quiz.js";
|
||||
import { isExpiredChatImageMessage } from "./message-utils.js";
|
||||
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
|
||||
import { escapePresenceText } from "./vip-presence.js";
|
||||
@@ -50,6 +56,80 @@ function parseBracketUsers(content, color = "#000099") {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建统一的猜谜活动标题与题型标签。
|
||||
*/
|
||||
function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
|
||||
const { activityLabel, typeLabel } = buildQuizActivityTitle(msg);
|
||||
|
||||
return `
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:11px;font-weight:800;letter-spacing:.04em;">${escapeHtml(activityLabel)}</span>
|
||||
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:11px;font-weight:700;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 猜谜活动开题消息统一渲染为卡片。
|
||||
*/
|
||||
function buildQuizStartHtml(msg, timeStr) {
|
||||
const quizMeta = normalizeQuizRoundPayload(msg);
|
||||
const rawHint = String(quizMeta.hint || msg.content || "")
|
||||
.replace(/^🧩\s*/, "")
|
||||
.replace(/^📣\s*/, "")
|
||||
.replace(/^【[^】]+】\s*第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
|
||||
.replace(/^【[^】]+】\s*/u, "")
|
||||
.replace(/^第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
|
||||
.replace(/^题面:\s*/u, "")
|
||||
.trim();
|
||||
const safeHint = escapeHtml(rawHint);
|
||||
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#faf5ff);border:1px solid rgba(124,58,237,.16);box-shadow:0 6px 16px rgba(124,58,237,.08);overflow:hidden;">
|
||||
<div style="width:30px;height:30px;border-radius:10px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:17px;box-shadow:0 5px 12px rgba(124,58,237,.18);flex-shrink:0;">🧩</div>
|
||||
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#312e81;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg)}</div>
|
||||
<div data-quiz-inline-text style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:13px;line-height:1.35;font-weight:700;min-width:220px;flex:1;">
|
||||
<span>${safeHint}</span>
|
||||
<span class="msg-time" style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
|
||||
<span data-quiz-inline-action-anchor></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;color:#6d28d9;font-size:11px;flex-shrink:0;margin-left:auto;">
|
||||
<span style="padding:1px 7px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
|
||||
<span style="padding:1px 7px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 猜谜活动结算消息统一渲染为结果卡片。
|
||||
*/
|
||||
function buildQuizResultHtml(msg, timeStr) {
|
||||
const quizMeta = normalizeQuizRoundPayload(msg);
|
||||
const winnerHtml = clickableUser(String(msg.winner_username || ""), "#15803d");
|
||||
const answerText = escapeHtml(quizMeta.answer || String(msg.content || ""));
|
||||
|
||||
return `
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;padding:10px 12px;border-radius:14px;background:linear-gradient(135deg,#f0fdf4,#ecfccb);border:1px solid rgba(22,163,74,.18);box-shadow:0 10px 24px rgba(34,197,94,.10);">
|
||||
<div style="width:42px;height:42px;border-radius:12px;background:linear-gradient(135deg,#16a34a,#4ade80);display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;box-shadow:0 8px 18px rgba(22,163,74,.22);flex-shrink:0;">🎉</div>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
${buildQuizBadgeHtml(msg, "#16a34a")}
|
||||
<span class="msg-time">(${timeStr})</span>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:15px;line-height:1.75;color:#166534;font-weight:700;">【${winnerHtml}】率先答对「${answerText}」</div>
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#166534;font-size:12px;">
|
||||
<span style="padding:2px 8px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(22,163,74,.12);">💰 ${quizMeta.rewardGold} 金币</span>
|
||||
<span style="padding:2px 8px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(22,163,74,.12);">⭐ ${quizMeta.rewardExp} 经验</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
|
||||
*/
|
||||
@@ -121,14 +201,10 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
|
||||
state.trackMaxMsgId(msg.id || 0);
|
||||
|
||||
const idiomRoundId = Number.parseInt(
|
||||
String(msg.idiom_game_round_id || msg.idom_game_round_id || "0"),
|
||||
10,
|
||||
);
|
||||
const isIdiomStartMessage = idiomRoundId > 0
|
||||
&& msg.from_user === "星海小博士"
|
||||
&& !msg.action
|
||||
&& String(msg.content || "").includes("猜成语时间");
|
||||
const quizMeta = normalizeQuizRoundPayload(msg);
|
||||
const idiomRoundId = quizMeta.roundId;
|
||||
const isIdiomStartMessage = isQuizStartMessage(msg)
|
||||
&& ["星海小博士", "系统传音"].includes(String(msg.from_user || ""));
|
||||
|
||||
if (isIdiomStartMessage) {
|
||||
const existingIdiomNode = document.querySelector(`[data-idiom-round-id="${idiomRoundId}"]`);
|
||||
@@ -150,6 +226,7 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
}
|
||||
if (idiomRoundId > 0) {
|
||||
div.dataset.idiomRoundId = String(idiomRoundId);
|
||||
div.dataset.quizRoundId = String(idiomRoundId);
|
||||
}
|
||||
if (blockRuleKey) {
|
||||
div.dataset.blockKey = blockRuleKey;
|
||||
@@ -210,12 +287,13 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
html = `${iconImg} ${parsedContent}`;
|
||||
} else if (msg.action === "idiom_result") {
|
||||
div.dataset.idiomResult = "1";
|
||||
const winnerUsername = String(msg.winner_username || "");
|
||||
const winnerHtml = clickableUser(winnerUsername, "#16a34a");
|
||||
const answerText = escapeHtml(String(msg.idiom_answer || ""));
|
||||
const rewardGold = Number.parseInt(String(msg.idiom_result_reward_gold ?? msg.reward_gold ?? 0), 10);
|
||||
const rewardExp = Number.parseInt(String(msg.idiom_result_reward_exp ?? msg.reward_exp ?? 0), 10);
|
||||
html = `<span style="color:#16a34a;font-weight:bold;">🎉 【${winnerHtml}】率先答对成语「${answerText}」,获得 ${rewardGold} 金币、${rewardExp} 经验!</span>`;
|
||||
div.dataset.quizRoundEndedId = String(quizMeta.endedRoundId || quizMeta.roundId || 0);
|
||||
div.dataset.quizWinnerUsername = String(msg.winner_username || "");
|
||||
const parsedContent = parseBracketUsers(msg.content);
|
||||
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}:</span><span class="msg-content${textColorClass}" style="color: ${fontColor};">${parsedContent}</span>`;
|
||||
} else if (isIdiomStartMessage) {
|
||||
html = buildQuizStartHtml(msg, timeStr);
|
||||
timeStrOverride = true;
|
||||
} else if (msg.action === "vip_presence") {
|
||||
const accent = msg.presence_color || "#f59e0b";
|
||||
div.style.cssText =
|
||||
@@ -278,6 +356,7 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
|
||||
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
|
||||
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
|
||||
const isQuizStartNotification = isIdiomStartMessage || content.includes("猜谜活动") || content.includes("猜成语时间");
|
||||
const isPlainNotification =
|
||||
content.includes("【百家乐】") ||
|
||||
content.includes("【赛马】") ||
|
||||
@@ -287,7 +366,23 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
content.includes("【老虎机】") ||
|
||||
content.includes("购买了");
|
||||
|
||||
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
|
||||
if (isQuizStartNotification) {
|
||||
div.style.cssText =
|
||||
"background:linear-gradient(135deg,#fff7ed,#fffbeb);border:1px solid rgba(245,158,11,.28);border-left:4px solid #f59e0b;border-radius:12px;padding:8px 12px;margin:4px 0;box-shadow:0 10px 24px rgba(245,158,11,.14);";
|
||||
html = `
|
||||
<div style="display:flex;align-items:flex-start;gap:10px;">
|
||||
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
${buildQuizBadgeHtml(msg, "#d97706")}
|
||||
<span class="msg-time">(${timeStr})</span>
|
||||
</div>
|
||||
<div style="margin-top:7px;color:#9a3412;font-size:15px;font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
timeStrOverride = true;
|
||||
} else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
|
||||
let plainAccentContent = parseBracketUsers(msg.content);
|
||||
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
|
||||
} else if (isPlainNotification) {
|
||||
@@ -346,9 +441,9 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
div.innerHTML = html;
|
||||
attachIdiomAnswerButton(div, msg);
|
||||
|
||||
// 历史消息恢复或实时结算时,都立即移除对应回合的旧答题按钮。
|
||||
if (Number.parseInt(String(msg.idiom_game_round_ended_id || "0"), 10) > 0) {
|
||||
removeIdiomAnswerButtons(Number.parseInt(String(msg.idiom_game_round_ended_id), 10));
|
||||
// 历史消息恢复或实时结算时,都立即把对应回合按钮置为结束态,保留消息结构便于回看。
|
||||
if (quizMeta.endedRoundId > 0) {
|
||||
disableIdiomAnswerButtons(quizMeta.endedRoundId, "本回合已结束", String(msg.winner_username || ""));
|
||||
}
|
||||
|
||||
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
|
||||
|
||||
@@ -478,12 +478,21 @@ export function resolveBlockedSystemSenderKey(msg) {
|
||||
const content = String(msg?.content || "");
|
||||
const action = String(msg?.action || "");
|
||||
const idiomRoundId = Number.parseInt(
|
||||
String(msg?.idiom_game_round_id || msg?.idom_game_round_id || msg?.idiom_game_round_ended_id || "0"),
|
||||
String(msg?.quiz_round_id || msg?.idiom_game_round_id || msg?.idom_game_round_id || msg?.quiz_round_ended_id || msg?.idiom_game_round_ended_id || "0"),
|
||||
10,
|
||||
);
|
||||
const quizType = String(msg?.quiz_type || "");
|
||||
const quizTypeLabel = String(msg?.quiz_type_label || "");
|
||||
|
||||
// 猜成语消息独立作为一个通知类型管理,不再复用“星海小博士”的屏蔽规则。
|
||||
if (idiomRoundId > 0 || action === "idiom_result" || (fromUser === "星海小博士" && content.includes("猜成语"))) {
|
||||
// 猜谜活动消息独立作为一个通知类型管理,不再复用“星海小博士”的屏蔽规则。
|
||||
if (
|
||||
idiomRoundId > 0 ||
|
||||
action === "idiom_result" ||
|
||||
quizType === "idiom" ||
|
||||
quizTypeLabel.includes("成语") ||
|
||||
(fromUser === "星海小博士" && (content.includes("猜成语") || content.includes("猜谜活动"))) ||
|
||||
((fromUser === "系统传音" || fromUser === "系统") && (content.includes("猜成语") || content.includes("猜谜活动")))
|
||||
) {
|
||||
return "猜成语";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
// 猜谜活动前端模块
|
||||
// 监听 RiddleGameStarted / RiddleGameAnswered 事件,提供答题弹窗与刷新恢复能力。
|
||||
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
let currentRoundId = 0;
|
||||
let currentRoomId = 0;
|
||||
let currentQuizType = "idiom";
|
||||
const QUIZ_TYPES = ["idiom", "brain_teaser"];
|
||||
|
||||
/**
|
||||
* 兼容新旧字段,提取前端统一使用的猜谜活动回合信息。
|
||||
*/
|
||||
export function normalizeQuizRoundPayload(payload) {
|
||||
const source = payload && typeof payload === "object" ? payload : {};
|
||||
const quizType = String(source.quiz_type || source.idiom_type || "idiom");
|
||||
const quizTypeLabel = String(source.quiz_type_label || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
|
||||
const roundId = Number.parseInt(
|
||||
String(source.quiz_round_id || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
|
||||
10,
|
||||
);
|
||||
const endedRoundId = Number.parseInt(
|
||||
String(source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
|
||||
10,
|
||||
);
|
||||
const rewardGold = Number.parseInt(
|
||||
String(source.quiz_reward_gold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? 0),
|
||||
10,
|
||||
);
|
||||
const rewardExp = Number.parseInt(
|
||||
String(source.quiz_reward_exp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? 0),
|
||||
10,
|
||||
);
|
||||
const hint = String(source.quiz_hint || source.hint || source.content || "");
|
||||
const answer = String(source.quiz_answer || source.idiom_answer || source.answer || "");
|
||||
|
||||
return {
|
||||
quizType,
|
||||
quizTypeLabel,
|
||||
roundId: Number.isNaN(roundId) ? 0 : roundId,
|
||||
endedRoundId: Number.isNaN(endedRoundId) ? 0 : endedRoundId,
|
||||
rewardGold: Number.isNaN(rewardGold) ? 0 : rewardGold,
|
||||
rewardExp: Number.isNaN(rewardExp) ? 0 : rewardExp,
|
||||
hint,
|
||||
answer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一构建“猜谜活动 + 题型”展示标题。
|
||||
*/
|
||||
export function buildQuizActivityTitle(payload) {
|
||||
const quizMeta = normalizeQuizRoundPayload(payload);
|
||||
|
||||
return {
|
||||
activityLabel: "猜谜活动",
|
||||
typeLabel: quizMeta.quizTypeLabel || "谜题",
|
||||
quizType: quizMeta.quizType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一条消息是否属于开题消息。
|
||||
*/
|
||||
export function isQuizStartMessage(payload) {
|
||||
const quizMeta = normalizeQuizRoundPayload(payload);
|
||||
const action = String(payload?.action || "");
|
||||
|
||||
return quizMeta.roundId > 0 && quizMeta.endedRoundId <= 0 && !action;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找当前回合是否已经有对应的聊天室消息节点。
|
||||
*/
|
||||
function findIdiomRoundMessageNode(roundId) {
|
||||
if (roundId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return document.querySelector(`[data-idiom-round-id="${roundId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新后若历史消息里缺少当前进行中的开题卡片,则主动补回一条系统传音消息。
|
||||
*/
|
||||
function restoreCurrentQuizMessage(roomId, payload) {
|
||||
const quizMeta = normalizeQuizRoundPayload(payload);
|
||||
if (quizMeta.roundId <= 0 || !quizMeta.hint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (findIdiomRoundMessageNode(quizMeta.roundId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { activityLabel, typeLabel } = buildQuizActivityTitle(payload);
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
|
||||
window.appendMessage?.({
|
||||
id: `quiz-start-restore-${quizMeta.roundId}`,
|
||||
room_id: roomId,
|
||||
from_user: "系统传音",
|
||||
to_user: "大家",
|
||||
content: `🧩 【${activityLabel}|${typeLabel}】${quizMeta.hint}`,
|
||||
is_secret: false,
|
||||
font_color: "#7c3aed",
|
||||
action: "",
|
||||
quiz_type: quizMeta.quizType,
|
||||
quiz_type_label: typeLabel,
|
||||
quiz_round_id: quizMeta.roundId,
|
||||
quiz_hint: quizMeta.hint,
|
||||
quiz_reward_gold: quizMeta.rewardGold,
|
||||
quiz_reward_exp: quizMeta.rewardExp,
|
||||
idiom_game_round_id: quizMeta.roundId,
|
||||
idiom_reward_gold: quizMeta.rewardGold,
|
||||
idiom_reward_exp: quizMeta.rewardExp,
|
||||
sent_at: timeStr,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定回合创建统一样式的答题按钮。
|
||||
*/
|
||||
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp, typeLabel, quizType = "idiom") {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.idiomAnswerBtn = String(roundId);
|
||||
btn.dataset.quizAnswerBtn = String(roundId);
|
||||
btn.dataset.idiomHint = hint;
|
||||
btn.dataset.quizHint = hint;
|
||||
btn.dataset.idiomGold = String(rewardGold);
|
||||
btn.dataset.quizGold = String(rewardGold);
|
||||
btn.dataset.idiomExp = String(rewardExp);
|
||||
btn.dataset.quizExp = String(rewardExp);
|
||||
btn.dataset.quizTypeLabel = typeLabel;
|
||||
btn.dataset.quizType = quizType;
|
||||
btn.dataset.quizEnded = "0";
|
||||
btn.textContent = "🎯 立即答题";
|
||||
btn.style.cssText =
|
||||
"padding:4px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
|
||||
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
|
||||
"font-weight:700;line-height:1.2;vertical-align:middle;box-shadow:0 4px 10px rgba(124,58,237,.18);";
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指定回合的所有答题按钮。
|
||||
*/
|
||||
function queryQuizAnswerButtons(roundId = 0) {
|
||||
const selector = roundId > 0
|
||||
? `[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`
|
||||
: "[data-quiz-answer-btn], [data-idiom-answer-btn]";
|
||||
|
||||
return Array.from(document.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前页面上该回合已渲染的结算消息,用于历史恢复时补挂答对人名字。
|
||||
*/
|
||||
function findQuizWinnerUsername(roundId = 0) {
|
||||
if (roundId <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const resultNode = document.querySelector(`[data-quiz-round-ended-id="${roundId}"]`);
|
||||
|
||||
return String(resultNode?.dataset?.quizWinnerUsername || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定回合的所有答题按钮。
|
||||
*/
|
||||
export function removeIdiomAnswerButtons(roundId = 0) {
|
||||
queryQuizAnswerButtons(roundId).forEach((button) => button.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* 为结束态按钮补一个答对人标记,避免用户只看到“已结束”不知道是谁抢到了。
|
||||
*/
|
||||
function syncQuizWinnerLabel(button, winnerUsername = "") {
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingLabel = button.parentElement?.querySelector(`[data-quiz-winner-label="${button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || ""}"]`);
|
||||
if (!winnerUsername) {
|
||||
existingLabel?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const winnerLabel = existingLabel || document.createElement("span");
|
||||
winnerLabel.dataset.quizWinnerLabel = String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0");
|
||||
winnerLabel.textContent = `答对:${winnerUsername}`;
|
||||
winnerLabel.style.cssText = "margin-left:6px;font-size:11px;line-height:1.2;color:#64748b;font-weight:700;white-space:nowrap;";
|
||||
|
||||
if (!existingLabel) {
|
||||
button.insertAdjacentElement("afterend", winnerLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
|
||||
*/
|
||||
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
|
||||
queryQuizAnswerButtons(roundId).forEach((button) => {
|
||||
button.disabled = true;
|
||||
button.dataset.quizEnded = "1";
|
||||
button.style.background = "linear-gradient(135deg,#94a3b8,#cbd5e1)";
|
||||
button.style.color = "#f8fafc";
|
||||
button.style.cursor = "not-allowed";
|
||||
button.style.boxShadow = "none";
|
||||
button.style.opacity = ".92";
|
||||
button.style.padding = "4px 12px";
|
||||
button.style.fontSize = "11px";
|
||||
button.style.lineHeight = "1.2";
|
||||
button.title = endedText;
|
||||
button.textContent = "已结束";
|
||||
syncQuizWinnerLabel(button, winnerUsername);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前回合状态同步按钮可点击性,避免刷新后仍显示过期入口。
|
||||
*/
|
||||
function syncQuizAnswerButtons(activeRoundIds) {
|
||||
const activeIds = new Set((Array.isArray(activeRoundIds) ? activeRoundIds : [activeRoundIds]).filter((roundId) => roundId > 0));
|
||||
|
||||
queryQuizAnswerButtons().forEach((button) => {
|
||||
const buttonRoundId = Number.parseInt(String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0"), 10);
|
||||
if (activeIds.has(buttonRoundId)) {
|
||||
button.disabled = false;
|
||||
button.dataset.quizEnded = "0";
|
||||
button.style.background = "linear-gradient(135deg,#7c3aed,#a78bfa)";
|
||||
button.style.color = "#fff";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.boxShadow = "0 4px 10px rgba(124,58,237,.18)";
|
||||
button.style.opacity = "1";
|
||||
button.style.padding = "4px 12px";
|
||||
button.style.fontSize = "11px";
|
||||
button.style.lineHeight = "1.2";
|
||||
button.title = "";
|
||||
button.textContent = "🎯 立即答题";
|
||||
syncQuizWinnerLabel(button, "");
|
||||
return;
|
||||
}
|
||||
|
||||
disableIdiomAnswerButtons(buttonRoundId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
|
||||
*/
|
||||
export function attachIdiomAnswerButton(messageNode, message) {
|
||||
if (!messageNode || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quizMeta = normalizeQuizRoundPayload(message);
|
||||
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
|
||||
if (roundId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (quizMeta.endedRoundId > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["星海小博士", "系统传音"].includes(String(message.from_user || ""))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageNode.querySelector(`[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = buildIdiomAnswerButton(roundId, quizMeta.hint, quizMeta.rewardGold, quizMeta.rewardExp, quizMeta.quizTypeLabel, quizMeta.quizType);
|
||||
const inlineActionAnchor = messageNode.querySelector("[data-quiz-inline-action-anchor]");
|
||||
|
||||
if (inlineActionAnchor?.parentNode) {
|
||||
inlineActionAnchor.parentNode.insertBefore(button, inlineActionAnchor.nextSibling);
|
||||
} else {
|
||||
messageNode.appendChild(button);
|
||||
}
|
||||
|
||||
if (quizMeta.endedRoundId > 0) {
|
||||
disableIdiomAnswerButtons(roundId);
|
||||
return;
|
||||
}
|
||||
|
||||
const winnerUsername = findQuizWinnerUsername(roundId);
|
||||
if (winnerUsername) {
|
||||
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
|
||||
*/
|
||||
async function syncCurrentIdiomRound() {
|
||||
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
|
||||
if (roomId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentRoomId = roomId;
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(QUIZ_TYPES.map(async (quizType) => {
|
||||
const response = await fetch(`/riddle-quiz/current?room_id=${roomId}&type=${encodeURIComponent(quizType)}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}));
|
||||
responses.forEach((data) => {
|
||||
if (data?.status === "success" && data?.data) {
|
||||
restoreCurrentQuizMessage(roomId, data.data);
|
||||
}
|
||||
});
|
||||
const activeRoundIds = responses
|
||||
.map((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10))
|
||||
.filter((roundId) => roundId > 0);
|
||||
|
||||
currentRoundId = activeRoundIds[0] || 0;
|
||||
currentQuizType = responses.find((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10) === currentRoundId)?.data?.quiz_type || currentQuizType;
|
||||
syncQuizAnswerButtons(activeRoundIds);
|
||||
} catch (_error) {
|
||||
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
|
||||
*/
|
||||
function handleRiddleGameStarted(e) {
|
||||
const quizMeta = normalizeQuizRoundPayload(e.detail);
|
||||
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
|
||||
const { roundId, hint, rewardGold, rewardExp } = quizMeta;
|
||||
const message = String(e.detail?.message || "");
|
||||
if (!roundId || !hint) return;
|
||||
|
||||
currentRoundId = roundId;
|
||||
currentRoomId = window.chatContext?.roomId || 0;
|
||||
currentQuizType = quizMeta.quizType || "idiom";
|
||||
|
||||
// 线上如果 MessageSent 补消息没有到达,这里主动补一条公屏消息兜底;
|
||||
// 本地或正常链路下若消息已存在,则只补挂答题按钮,避免重复渲染。
|
||||
const existingMessageNode = findIdiomRoundMessageNode(roundId);
|
||||
if (existingMessageNode) {
|
||||
attachIdiomAnswerButton(existingMessageNode, {
|
||||
from_user: "星海小博士",
|
||||
content: message || `🧩 【${activityLabel}|${typeLabel}】${hint}`,
|
||||
quiz_type: quizMeta.quizType,
|
||||
quiz_type_label: typeLabel,
|
||||
quiz_round_id: roundId,
|
||||
quiz_reward_gold: rewardGold,
|
||||
quiz_reward_exp: rewardExp,
|
||||
idiom_game_round_id: roundId,
|
||||
idiom_reward_gold: rewardGold,
|
||||
idiom_reward_exp: rewardExp,
|
||||
});
|
||||
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
window.appendMessage?.({
|
||||
id: `quiz-start-live-${roundId}`,
|
||||
room_id: currentRoomId || window.chatContext?.roomId || 0,
|
||||
from_user: "系统传音",
|
||||
to_user: "大家",
|
||||
content: message || `🧩 【${activityLabel}|${typeLabel}】${hint}`,
|
||||
is_secret: false,
|
||||
font_color: "#7c3aed",
|
||||
action: "",
|
||||
quiz_type: quizMeta.quizType,
|
||||
quiz_type_label: typeLabel,
|
||||
quiz_round_id: roundId,
|
||||
quiz_reward_gold: rewardGold,
|
||||
quiz_reward_exp: rewardExp,
|
||||
idiom_game_round_id: roundId,
|
||||
idiom_reward_gold: rewardGold,
|
||||
idiom_reward_exp: rewardExp,
|
||||
sent_at: timeStr,
|
||||
});
|
||||
|
||||
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到猜成语结果事件。
|
||||
*/
|
||||
function handleRiddleGameAnswered(e) {
|
||||
const quizMeta = normalizeQuizRoundPayload(e.detail);
|
||||
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
|
||||
const answer = quizMeta.answer;
|
||||
const winnerUsername = String(e.detail?.winner_username || "");
|
||||
const rewardGold = quizMeta.rewardGold;
|
||||
const rewardExp = quizMeta.rewardExp;
|
||||
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
|
||||
if (!answer) return;
|
||||
|
||||
currentRoundId = 0;
|
||||
currentQuizType = "idiom";
|
||||
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
|
||||
|
||||
// 关闭当前用户的答题弹窗(如果开着的话)
|
||||
const answerModal = document.getElementById("idiom-answer-modal");
|
||||
if (answerModal && answerModal.style.display !== "none") {
|
||||
answerModal.style.display = "none";
|
||||
}
|
||||
|
||||
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
window.appendMessage?.({
|
||||
id: `quiz-result-live-${roundId}-${Date.now()}`,
|
||||
room_id: currentRoomId || window.chatContext?.roomId || 0,
|
||||
from_user: "系统传音",
|
||||
to_user: "大家",
|
||||
content: `🎉 【${winnerUsername}】率先答对${typeLabel}「${answer}」,获得 ${rewardGold} 金币、${rewardExp} 经验!`,
|
||||
is_secret: false,
|
||||
font_color: "#16a34a",
|
||||
action: "idiom_result",
|
||||
winner_username: winnerUsername,
|
||||
quiz_type: quizMeta.quizType,
|
||||
quiz_type_label: typeLabel,
|
||||
quiz_answer: answer,
|
||||
quiz_reward_gold: rewardGold,
|
||||
quiz_reward_exp: rewardExp,
|
||||
quiz_round_ended_id: roundId,
|
||||
idiom_answer: answer,
|
||||
idiom_result_reward_gold: rewardGold,
|
||||
idiom_result_reward_exp: rewardExp,
|
||||
idiom_game_round_ended_id: roundId,
|
||||
sent_at: timeStr,
|
||||
});
|
||||
|
||||
// ── Toast 通知(所有用户都能看到) ──
|
||||
window.chatToast?.show({
|
||||
title: `🧩 ${activityLabel} · ${typeLabel}`,
|
||||
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
|
||||
icon: "🎉",
|
||||
color: "#16a34a",
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开答题弹窗。
|
||||
*/
|
||||
function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel = "成语题", quizType = "idiom") {
|
||||
currentRoundId = roundId;
|
||||
currentRoomId = window.chatContext?.roomId || 0;
|
||||
currentQuizType = quizType || "idiom";
|
||||
|
||||
const modal = document.getElementById("idiom-answer-modal");
|
||||
if (!modal) return;
|
||||
|
||||
const hintEl = document.getElementById("idiom-answer-hint");
|
||||
const rewardEl = document.getElementById("idiom-answer-reward");
|
||||
const typeEl = document.getElementById("idiom-answer-type");
|
||||
if (hintEl) hintEl.textContent = hint;
|
||||
if (rewardEl) rewardEl.textContent = `🎁 答对奖励:${rewardGold} 金币 + ${rewardExp} 经验`;
|
||||
if (typeEl) typeEl.textContent = typeLabel;
|
||||
|
||||
modal.style.display = "flex";
|
||||
|
||||
const input = document.getElementById("idiom-answer-input");
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.focus();
|
||||
input.disabled = false;
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById("idiom-answer-submit");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交答案";
|
||||
}
|
||||
|
||||
const feedbackEl = document.getElementById("idiom-answer-feedback");
|
||||
if (feedbackEl) feedbackEl.textContent = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭答题弹窗。
|
||||
*/
|
||||
function closeIdiomAnswerModal() {
|
||||
const modal = document.getElementById("idiom-answer-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交答案。
|
||||
*/
|
||||
async function submitIdiomAnswer() {
|
||||
const input = document.getElementById("idiom-answer-input");
|
||||
const feedbackEl = document.getElementById("idiom-answer-feedback");
|
||||
const submitBtn = document.getElementById("idiom-answer-submit");
|
||||
|
||||
if (!input || !feedbackEl || !submitBtn) return;
|
||||
|
||||
const answer = input.value.trim();
|
||||
if (!answer) {
|
||||
feedbackEl.textContent = "请输入答案";
|
||||
feedbackEl.style.color = "#ef4444";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "提交中...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/riddle-quiz/answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
round_id: currentRoundId,
|
||||
answer: answer,
|
||||
room_id: currentRoomId,
|
||||
quiz_type: currentQuizType,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
feedbackEl.textContent = data.message || "🎉 回答正确!";
|
||||
feedbackEl.style.color = "#16a34a";
|
||||
input.disabled = true;
|
||||
disableIdiomAnswerButtons(
|
||||
currentRoundId,
|
||||
"本回合已结束",
|
||||
String(window.chatContext?.username || ""),
|
||||
);
|
||||
|
||||
// 延迟关闭弹窗
|
||||
setTimeout(() => {
|
||||
closeIdiomAnswerModal();
|
||||
}, 2000);
|
||||
} else {
|
||||
feedbackEl.textContent = data.message || "答案不正确";
|
||||
feedbackEl.style.color = "#ef4444";
|
||||
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
|
||||
disableIdiomAnswerButtons(currentRoundId, data.message || "本回合已结束");
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交答案";
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
} catch (error) {
|
||||
feedbackEl.textContent = "网络错误,请稍后重试";
|
||||
feedbackEl.style.color = "#ef4444";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交答案";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 事件绑定 ──
|
||||
|
||||
export function bindIdiomQuizControls() {
|
||||
// 已经绑定的不再重复绑定
|
||||
if (document.getElementById("idiom-answer-modal")?.dataset?.idiomBound) return;
|
||||
const modal = document.getElementById("idiom-answer-modal");
|
||||
if (modal) modal.dataset.idiomBound = "1";
|
||||
|
||||
// 关闭按钮
|
||||
document.addEventListener("click", (e) => {
|
||||
const closeBtn = e.target.closest("[data-idiom-answer-close]");
|
||||
if (closeBtn) {
|
||||
closeIdiomAnswerModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击遮罩层关闭
|
||||
const overlay = e.target.closest("#idiom-answer-modal");
|
||||
if (overlay && e.target === overlay) {
|
||||
closeIdiomAnswerModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 提交按钮
|
||||
document.addEventListener("click", (e) => {
|
||||
const submitBtn = e.target.closest("[data-idiom-answer-submit]");
|
||||
if (submitBtn) {
|
||||
e.preventDefault();
|
||||
submitIdiomAnswer();
|
||||
}
|
||||
});
|
||||
|
||||
// 输入框 Enter 提交
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const input = e.target.closest("#idiom-answer-input");
|
||||
if (input && e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitIdiomAnswer();
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天消息中的【答题】按钮点击
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-idiom-answer-btn]");
|
||||
if (!btn) return;
|
||||
|
||||
if (btn instanceof HTMLButtonElement && (btn.disabled || btn.dataset.quizEnded === "1")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roundId = parseInt(btn.dataset.quizAnswerBtn || btn.dataset.idiomAnswerBtn || "0", 10);
|
||||
const hint = btn.dataset.quizHint || btn.dataset.idiomHint || "";
|
||||
const rewardGold = parseInt(btn.dataset.quizGold || btn.dataset.idiomGold || "0", 10);
|
||||
const rewardExp = parseInt(btn.dataset.quizExp || btn.dataset.idiomExp || "0", 10);
|
||||
const typeLabel = btn.dataset.quizTypeLabel || "成语题";
|
||||
currentQuizType = btn.dataset.quizType || (typeLabel === "脑筋急转弯" ? "brain_teaser" : "idiom");
|
||||
|
||||
if (roundId > 0) {
|
||||
openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel, currentQuizType);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
|
||||
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
|
||||
|
||||
window.setTimeout(() => {
|
||||
syncCurrentIdiomRound();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ── 挂载到 window ──
|
||||
window.openIdiomAnswerModal = openIdiomAnswerModal;
|
||||
window.closeIdiomAnswerModal = closeIdiomAnswerModal;
|
||||
window.submitIdiomAnswer = submitIdiomAnswer;
|
||||
window.handleRiddleGameStarted = handleRiddleGameStarted;
|
||||
window.handleRiddleGameAnswered = handleRiddleGameAnswered;
|
||||
@@ -277,12 +277,12 @@ export function initChat(roomId) {
|
||||
window.dispatchEvent(new CustomEvent("chat:pat", { detail: e }));
|
||||
})
|
||||
// 监听猜成语出题
|
||||
.listen("IdiomGameStarted", (e) => {
|
||||
.listen("RiddleGameStarted", (e) => {
|
||||
console.log("猜成语:", e);
|
||||
window.dispatchEvent(new CustomEvent("chat:idiom-started", { detail: e }));
|
||||
})
|
||||
// 监听猜成语答题结果
|
||||
.listen("IdiomGameAnswered", (e) => {
|
||||
.listen("RiddleGameAnswered", (e) => {
|
||||
console.log("猜成语结果:", e);
|
||||
window.dispatchEvent(new CustomEvent("chat:idiom-answered", { detail: e }));
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', '猜成语题库管理')
|
||||
|
||||
@section('content')
|
||||
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
|
||||
|
||||
@php
|
||||
$idiomPayload = $idioms->mapWithKeys(
|
||||
fn($item) => [
|
||||
(string) $item->id => [
|
||||
'id' => $item->id,
|
||||
'answer' => $item->answer,
|
||||
'hint' => $item->hint,
|
||||
'sort' => $item->sort,
|
||||
'is_active' => (bool) $item->is_active,
|
||||
'update_url' => route('admin.idioms.update', $item->id),
|
||||
'toggle_url' => route('admin.idioms.toggle', $item->id),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$idiomConfig = \App\Models\GameConfig::forGame('idiom');
|
||||
$idiomParams = $idiomConfig?->params ?? [];
|
||||
@endphp
|
||||
|
||||
<script type="application/json" id="admin-idioms-data">@json($idiomPayload)</script>
|
||||
|
||||
<div class="{{ $adminListPageClass }}">
|
||||
|
||||
{{-- 页头 --}}
|
||||
<div class="{{ $adminListHeaderCardClass }}">
|
||||
<div>
|
||||
<h2 class="{{ $adminListHeaderTitleClass }}">🧩 猜成语题库管理</h2>
|
||||
<p class="{{ $adminListHeaderSubtitleClass }}">
|
||||
管理猜成语游戏的题目库,共 <strong class="text-indigo-600">{{ $idioms->count() }}</strong> 条题目
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 游戏参数 + 出题 --}}
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<div class="{{ $adminListSectionHeadClass }}">
|
||||
<h3 class="{{ $adminListSectionTitleClass }}">⚙️ 游戏参数</h3>
|
||||
</div>
|
||||
<form action="{{ route('admin.idioms.settings.save') }}" method="POST" class="p-5">
|
||||
@csrf
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">答对奖励金币</label>
|
||||
<input type="number" name="reward_gold"
|
||||
value="{{ old('reward_gold', $idiomParams['reward_gold'] ?? 50) }}" min="0"
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">答对奖励经验</label>
|
||||
<input type="number" name="reward_exp"
|
||||
value="{{ old('reward_exp', $idiomParams['reward_exp'] ?? 30) }}" min="0"
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">自动出题间隔(分钟)</label>
|
||||
<input type="number" name="auto_start_interval"
|
||||
value="{{ old('auto_start_interval', $idiomParams['auto_start_interval'] ?? 0) }}" min="0"
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
<p class="text-xs text-gray-400 mt-1">0=仅手动出题</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">题目过期时间(分钟)</label>
|
||||
<input type="number" name="expire_minutes"
|
||||
value="{{ old('expire_minutes', $idiomParams['expire_minutes'] ?? 5) }}" min="0"
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
<p class="text-xs text-gray-400 mt-1">0=不过期;大于 0 时超时会自动公布答案并结束回合</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
|
||||
💾 保存参数
|
||||
</button>
|
||||
<span class="text-sm text-gray-400">|</span>
|
||||
<label class="text-sm text-gray-600">选择房间:</label>
|
||||
<select id="idiom-start-room" class="border border-gray-300 rounded-lg px-3 py-1.5 text-sm">
|
||||
@foreach (\App\Models\Room::orderBy('id')->get() as $room)
|
||||
<option value="{{ $room->id }}">{{ $room->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" id="idiom-start-btn"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm font-bold rounded-lg hover:opacity-90 transition">
|
||||
🧩 出题
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 题目列表 --}}
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<div class="{{ $adminListTableWrapClass }}">
|
||||
<table class="{{ $adminListTableClass }}">
|
||||
<thead class="{{ $adminListTableHeadRowClass }}">
|
||||
<tr>
|
||||
<th class="{{ $adminListTableHeadCellClass }}">排序</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }}">成语答案</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} w-2/5">谜语提示</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-center">状态</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ $adminListTableBodyClass }}">
|
||||
@foreach ($idioms as $item)
|
||||
<tr id="row-{{ $item->id }}" class="{{ $adminListTableRowClass }} {{ $item->is_active ? '' : 'opacity-50' }}">
|
||||
<td class="px-4 py-3 {{ $adminListSecondaryTextClass }}">{{ $item->sort }}</td>
|
||||
<td class="px-4 py-3 font-bold {{ $adminListPrimaryTextClass }}">{{ $item->answer }}</td>
|
||||
<td class="px-4 py-3 {{ $adminListBodyTextClass }} text-sm">{{ $item->hint }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button type="button" data-idiom-toggle-id="{{ $item->id }}"
|
||||
id="toggle-{{ $item->id }}"
|
||||
class="{{ $adminListBadgeBaseClass }} px-2 py-1 transition
|
||||
{{ $item->is_active ? 'border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border-gray-200 bg-gray-100 text-gray-500 hover:bg-gray-200' }}">
|
||||
{{ $item->is_active ? '启用' : '禁用' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button type="button" data-idiom-edit-id="{{ $item->id }}"
|
||||
class="{{ $adminListActionButtonClass }} bg-indigo-50 text-indigo-700 hover:bg-indigo-100 mr-1">
|
||||
编辑
|
||||
</button>
|
||||
<form action="{{ route('admin.idioms.destroy', $item->id) }}" method="POST"
|
||||
class="inline" data-idiom-delete-confirm="确定删除题目「{{ $item->answer }}」?">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100">
|
||||
删除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 新增题目卡片 --}}
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<div class="{{ $adminListSectionHeadClass }}">
|
||||
<h3 class="{{ $adminListSectionTitleClass }}">➕ 新增成语题目</h3>
|
||||
</div>
|
||||
<form action="{{ route('admin.idioms.store') }}" method="POST" class="p-5">
|
||||
@csrf
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">成语答案</label>
|
||||
<input type="text" name="answer" value="{{ old('answer') }}" placeholder="画蛇添足" required
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">排序</label>
|
||||
<input type="number" name="sort" value="{{ old('sort', 0) }}" min="0"
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="{{ $adminListFilterLabelClass }}">谜语提示</label>
|
||||
<input type="text" name="hint" value="{{ old('hint') }}" placeholder="🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语" required
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
|
||||
💾 添加题目
|
||||
</button>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" name="is_active" value="1" checked class="rounded">
|
||||
立即启用
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- 编辑弹窗 --}}
|
||||
<div id="edit-modal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl w-full max-w-lg shadow-2xl">
|
||||
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 class="font-bold text-gray-800">✏️ 编辑成语题目</h3>
|
||||
<button type="button" data-idiom-edit-close class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||
</div>
|
||||
<form id="edit-form" method="POST" class="p-5">
|
||||
@csrf @method('PUT')
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">成语答案</label>
|
||||
<input type="text" name="answer" id="edit-answer" required
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">排序</label>
|
||||
<input type="number" name="sort" id="edit-sort" min="0"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">谜语提示</label>
|
||||
<input type="text" name="hint" id="edit-hint" required
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" name="is_active" id="edit-is-active" value="1" class="rounded">
|
||||
启用此题目
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
|
||||
💾 保存修改
|
||||
</button>
|
||||
<button type="button" data-idiom-edit-close
|
||||
class="{{ $adminListSecondaryButtonClass }}">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
{{-- 前端编辑/切换交互脚本 --}}
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const idiomsDataEl = document.getElementById('admin-idioms-data');
|
||||
if (!idiomsDataEl) return;
|
||||
const idiomsData = JSON.parse(idiomsDataEl.textContent || '{}');
|
||||
|
||||
// ── 打开编辑弹窗 ──
|
||||
document.querySelectorAll('[data-idiom-edit-id]').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = this.dataset.idiomEditId;
|
||||
const data = idiomsData[id];
|
||||
if (!data) return;
|
||||
|
||||
document.getElementById('edit-answer').value = data.answer;
|
||||
document.getElementById('edit-hint').value = data.hint;
|
||||
document.getElementById('edit-sort').value = data.sort;
|
||||
document.getElementById('edit-is-active').checked = data.is_active;
|
||||
document.getElementById('edit-form').action = data.update_url;
|
||||
document.getElementById('edit-modal').classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 关闭编辑弹窗 ──
|
||||
document.querySelectorAll('[data-idiom-edit-close]').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
document.getElementById('edit-modal').classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 切换启用/禁用(AJAX) ──
|
||||
document.querySelectorAll('[data-idiom-toggle-id]').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = this.dataset.idiomToggleId;
|
||||
const data = idiomsData[id];
|
||||
if (!data) return;
|
||||
|
||||
fetch(data.toggle_url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
const row = document.getElementById('row-' + id);
|
||||
if (row) row.style.opacity = res.is_active ? '1' : '0.5';
|
||||
const btn = document.getElementById('toggle-' + id);
|
||||
if (btn) {
|
||||
btn.textContent = res.is_active ? '启用' : '禁用';
|
||||
btn.className = (res.is_active
|
||||
? 'border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-500 hover:bg-gray-200')
|
||||
+ ' px-2 py-1 transition rounded-full text-xs font-semibold border';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => alert('操作失败'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 删除确认 ──
|
||||
document.querySelectorAll('[data-idiom-delete-confirm]').forEach(form => {
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!confirm(this.dataset.idiomDeleteConfirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 出题按钮 ──
|
||||
const startBtn = document.getElementById('idiom-start-btn');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', function () {
|
||||
const roomSelect = document.getElementById('idiom-start-room');
|
||||
const roomId = roomSelect?.value;
|
||||
if (!roomId) return;
|
||||
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '出题中...';
|
||||
|
||||
fetch('/idiom-quiz/start', {
|
||||
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: parseInt(roomId, 10) }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alert('✅ 出题成功!提示已发送到聊天室。');
|
||||
} else {
|
||||
alert(data.message || '出题失败');
|
||||
}
|
||||
})
|
||||
.catch(() => alert('网络错误,出题失败'))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🧩 出题';
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -100,9 +100,9 @@
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.fishing.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
🎣 钓鱼事件
|
||||
</a>
|
||||
<a href="{{ route('admin.idioms.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.idioms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
🧩 猜成语题库
|
||||
<a href="{{ route('admin.riddles.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.riddles.*') || request()->routeIs('admin.idioms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
🧩 猜谜活动题库
|
||||
</a>
|
||||
<a href="{{ route('admin.departments.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.departments.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', '猜谜活动题库管理')
|
||||
|
||||
@section('content')
|
||||
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
|
||||
|
||||
@php
|
||||
$quizTypes = $typeOptions;
|
||||
$idiomPayload = $idioms->mapWithKeys(
|
||||
fn ($item) => [
|
||||
(string) $item->id => [
|
||||
'id' => $item->id,
|
||||
'type' => $item->type,
|
||||
'answer' => $item->answer,
|
||||
'hint' => $item->hint,
|
||||
'sort' => $item->sort,
|
||||
'is_active' => (bool) $item->is_active,
|
||||
'update_url' => route('admin.riddles.update', $item->id),
|
||||
],
|
||||
],
|
||||
);
|
||||
@endphp
|
||||
|
||||
<script type="application/json" id="admin-idioms-data">@json($idiomPayload)</script>
|
||||
|
||||
<div class="{{ $adminListPageClass }}">
|
||||
<div class="{{ $adminListHeaderCardClass }}">
|
||||
<div>
|
||||
<h2 class="{{ $adminListHeaderTitleClass }}">🧩 猜谜活动题库管理</h2>
|
||||
<p class="{{ $adminListHeaderSubtitleClass }}">
|
||||
这里只管理题目本身;奖励、自动出题、参与房间和手动开题请前往
|
||||
<a href="{{ route('admin.game-configs.index') }}" class="font-semibold text-indigo-600 hover:text-indigo-500">游戏管理</a>
|
||||
页面操作。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<div class="{{ $adminListSectionHeadClass }}">
|
||||
<div>
|
||||
<h3 class="{{ $adminListSectionTitleClass }}">🔎 题库筛选</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">支持按题型和关键词快速定位题目。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action="{{ route('admin.riddles.index') }}" method="GET" class="p-5">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">题型</label>
|
||||
<select name="type" class="w-full {{ $adminListFilterInputClass }}">
|
||||
<option value="">全部题型</option>
|
||||
@foreach ($quizTypes as $quizType => $quizLabel)
|
||||
<option value="{{ $quizType }}" @selected($selectedType === $quizType)>{{ $quizLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">关键词</label>
|
||||
<input type="text" name="keyword" value="{{ $keyword }}" placeholder="匹配答案或题面" class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div class="flex items-end gap-3">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">筛选</button>
|
||||
<a href="{{ route('admin.riddles.index') }}" class="{{ $adminListSecondaryButtonClass }}">重置</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
@foreach ($quizTypes as $quizType => $quizLabel)
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1">
|
||||
{{ $quizLabel }}:{{ $typeStats[$quizType] ?? 0 }} 题
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<div class="{{ $adminListSectionHeadClass }}">
|
||||
<h3 class="{{ $adminListSectionTitleClass }}">📚 题目列表</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">题型</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">标准答案</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">题面 / 提示</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">排序</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">状态</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
@forelse ($idioms as $item)
|
||||
<tr class="hover:bg-slate-50/80">
|
||||
<td class="px-4 py-3 text-slate-500">#{{ $item->id }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2.5 py-1 text-xs font-semibold {{ $item->type === \App\Models\Riddle::TYPE_BRAIN_TEASER ? 'bg-amber-100 text-amber-700' : 'bg-indigo-100 text-indigo-700' }}">
|
||||
{{ \App\Models\Riddle::labelForType($item->type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-semibold text-slate-800">{{ $item->answer }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $item->hint }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ $item->sort }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button type="button"
|
||||
data-idiom-toggle-url="{{ route('admin.riddles.toggle', $item->id) }}"
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold transition {{ $item->is_active ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-slate-200 text-slate-500 hover:bg-slate-300' }}">
|
||||
{{ $item->is_active ? '已启用' : '已禁用' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button type="button" data-idiom-edit-id="{{ $item->id }}" class="{{ $adminListActionButtonClass }} bg-indigo-50 text-indigo-700 hover:bg-indigo-100">
|
||||
编辑
|
||||
</button>
|
||||
<form action="{{ route('admin.riddles.destroy', $item->id) }}" method="POST" class="inline" data-idiom-delete-confirm="确定删除题目「{{ $item->answer }}」?">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
|
||||
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
|
||||
<button type="submit" class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100">
|
||||
删除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-slate-400">当前筛选条件下暂无题目。</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<div class="{{ $adminListSectionHeadClass }}">
|
||||
<h3 class="{{ $adminListSectionTitleClass }}">➕ 新增题目</h3>
|
||||
</div>
|
||||
<form action="{{ route('admin.riddles.store') }}" method="POST" class="p-5">
|
||||
@csrf
|
||||
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
|
||||
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">题型</label>
|
||||
<select name="type" class="w-full {{ $adminListFilterInputClass }}">
|
||||
@foreach ($quizTypes as $quizType => $quizLabel)
|
||||
<option value="{{ $quizType }}" @selected(old('type', $selectedType ?: \App\Models\Riddle::TYPE_IDIOM) === $quizType)>{{ $quizLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">排序</label>
|
||||
<input type="number" name="sort" min="0" value="{{ old('sort', 0) }}" class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">标准答案</label>
|
||||
<input type="text" name="answer" value="{{ old('answer') }}" required placeholder="请输入标准答案" class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="{{ $adminListFilterLabelClass }}">题面 / 提示</label>
|
||||
<input type="text" name="hint" value="{{ old('hint') }}" required placeholder="请输入题面或提示文案" class="w-full {{ $adminListFilterInputClass }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">💾 添加题目</button>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="is_active" value="1" checked class="rounded border-slate-300">
|
||||
立即启用
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-lg rounded-xl bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 p-5">
|
||||
<h3 class="text-base font-bold text-slate-800">✏️ 编辑题目</h3>
|
||||
<button type="button" data-idiom-edit-close class="text-xl text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form id="edit-form" method="POST" class="p-5">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
|
||||
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-bold text-slate-600">题型</label>
|
||||
<select name="type" id="edit-type" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
|
||||
@foreach ($quizTypes as $quizType => $quizLabel)
|
||||
<option value="{{ $quizType }}">{{ $quizLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-bold text-slate-600">排序</label>
|
||||
<input type="number" name="sort" id="edit-sort" min="0" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-xs font-bold text-slate-600">标准答案</label>
|
||||
<input type="text" name="answer" id="edit-answer" required class="w-full rounded-lg border border-slate-300 p-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-xs font-bold text-slate-600">题面 / 提示</label>
|
||||
<input type="text" name="hint" id="edit-hint" required class="w-full rounded-lg border border-slate-300 p-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="is_active" id="edit-is-active" value="1" class="rounded border-slate-300">
|
||||
启用此题目
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">💾 保存修改</button>
|
||||
<button type="button" data-idiom-edit-close class="{{ $adminListSecondaryButtonClass }}">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const idiomsDataEl = document.getElementById('admin-idioms-data');
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const editForm = document.getElementById('edit-form');
|
||||
|
||||
if (!idiomsDataEl || !editModal || !editForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idiomsData = JSON.parse(idiomsDataEl.textContent || '{}');
|
||||
|
||||
document.querySelectorAll('[data-idiom-toggle-url]').forEach((button) => {
|
||||
button.addEventListener('click', async function () {
|
||||
const url = this.getAttribute('data-idiom-toggle-url');
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.ok) {
|
||||
alert(result.message || '状态切换失败。');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-idiom-delete-confirm]').forEach((form) => {
|
||||
form.addEventListener('submit', function (event) {
|
||||
const message = this.getAttribute('data-idiom-delete-confirm') || '确定删除这条题目吗?';
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-idiom-edit-id]').forEach((button) => {
|
||||
button.addEventListener('click', function () {
|
||||
const idiomId = this.getAttribute('data-idiom-edit-id') || '';
|
||||
const payload = idiomsData[idiomId];
|
||||
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('edit-type').value = payload.type;
|
||||
document.getElementById('edit-answer').value = payload.answer;
|
||||
document.getElementById('edit-hint').value = payload.hint;
|
||||
document.getElementById('edit-sort').value = payload.sort;
|
||||
document.getElementById('edit-is-active').checked = Boolean(payload.is_active);
|
||||
editForm.action = payload.update_url;
|
||||
editModal.classList.remove('hidden');
|
||||
editModal.classList.add('flex');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-idiom-edit-close]').forEach((button) => {
|
||||
button.addEventListener('click', function () {
|
||||
editModal.classList.add('hidden');
|
||||
editModal.classList.remove('flex');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -257,12 +257,16 @@
|
||||
style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:99999;justify-content:center;align-items:center;backdrop-filter:blur(3px);">
|
||||
<div style="background:#fff;border-radius:16px;width:min(90vw,460px);box-shadow:0 24px 64px rgba(0,0,0,.22);overflow:hidden;animation:gdSlideIn .2s ease;">
|
||||
<div style="padding:18px 22px;background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;">
|
||||
<div style="font-size:18px;font-weight:bold;">🧩 猜成语</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<div style="font-size:18px;font-weight:bold;">🧩 猜谜活动</div>
|
||||
<span id="idiom-answer-type"
|
||||
style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:rgba(255,255,255,.16);font-size:11px;font-weight:700;">成语题</span>
|
||||
</div>
|
||||
<div id="idiom-answer-reward" style="font-size:12px;margin-top:4px;opacity:.9;"></div>
|
||||
</div>
|
||||
<div style="padding:20px 22px;">
|
||||
<p id="idiom-answer-hint" style="font-size:15px;color:#1e293b;line-height:1.7;margin-bottom:16px;"></p>
|
||||
<input id="idiom-answer-input" type="text" autocomplete="off" placeholder="输入成语答案..."
|
||||
<input id="idiom-answer-input" type="text" autocomplete="off" placeholder="输入答案..."
|
||||
style="width:100%;box-sizing:border-box;padding:12px 14px;border:2px solid #e5e7eb;border-radius:10px;font-size:15px;outline:none;transition:border-color .2s;"
|
||||
onfocus="this.style.borderColor='#7c3aed'" onblur="this.style.borderColor='#e5e7eb'">
|
||||
<p id="idiom-answer-feedback" style="margin-top:8px;font-size:13px;min-height:20px;"></p>
|
||||
|
||||
@@ -138,7 +138,7 @@ $welcomeMessages = [
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
|
||||
<input type="checkbox" id="block-sender-idiom" data-chat-block-sender="猜成语">
|
||||
猜成语
|
||||
猜谜活动
|
||||
</label>
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
|
||||
|
||||
+4
-76
@@ -176,88 +176,16 @@ Schedule::call(function () {
|
||||
}
|
||||
})->everyMinute()->name('lottery:check')->withoutOverlapping();
|
||||
|
||||
// ──────────── 猜成语自动出题 ────────────────────────────────────
|
||||
// ──────────── 猜谜活动自动出题 ──────────────────────────────────
|
||||
//
|
||||
// 每分钟:检查是否到时间自动出题(仅 auto_start_interval > 0 时生效)
|
||||
// 每分钟:按房间范围 + 题型维度独立检查是否应自动出题
|
||||
Schedule::call(function () {
|
||||
if (! \App\Models\GameConfig::isEnabled('idiom')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先统一结算超时回合,避免旧题长期占用进行中状态。
|
||||
$roomId = 1;
|
||||
app(\App\Services\IdiomGameService::class)->expireActiveRoundsForRoom($roomId);
|
||||
|
||||
$config = \App\Models\GameConfig::forGame('idiom')?->params ?? [];
|
||||
$interval = (int) ($config['auto_start_interval'] ?? 0);
|
||||
if ($interval <= 0) {
|
||||
return; // 仅手动模式
|
||||
}
|
||||
|
||||
// 检查每个房间是否有进行中的回合(先只处理 1 号房间)
|
||||
$activeRound = \App\Models\IdiomGameRound::where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
if ($activeRound) {
|
||||
return; // 当前有未答完的题,跳过
|
||||
}
|
||||
|
||||
// 检查距上一题结束/创建时间是否已达到间隔
|
||||
$lastRound = \App\Models\IdiomGameRound::where('room_id', $roomId)
|
||||
->latest()
|
||||
->first();
|
||||
if ($lastRound) {
|
||||
$lastTime = $lastRound->ended_at ?? $lastRound->started_at ?? $lastRound->created_at;
|
||||
if ($lastTime && $lastTime->diffInMinutes(now()) < $interval) {
|
||||
return; // 还没到时间
|
||||
}
|
||||
}
|
||||
|
||||
// 随机选一道启用的题目
|
||||
$idiom = \App\Models\Idiom::where('is_active', true)->inRandomOrder()->first();
|
||||
if (! $idiom) {
|
||||
return; // 题库为空
|
||||
}
|
||||
|
||||
$rewardGold = (int) ($config['reward_gold'] ?? 50);
|
||||
$rewardExp = (int) ($config['reward_exp'] ?? 30);
|
||||
|
||||
// 创建新回合,并以 started_at 作为后续过期判断起点。
|
||||
$round = \App\Models\IdiomGameRound::create([
|
||||
'room_id' => $roomId,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
'reward_gold' => $rewardGold,
|
||||
'reward_exp' => $rewardExp,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// 广播到聊天室
|
||||
broadcast(new \App\Events\IdiomGameStarted(
|
||||
roomId: $roomId,
|
||||
hint: $idiom->hint,
|
||||
roundId: $round->id,
|
||||
rewardGold: $rewardGold,
|
||||
rewardExp: $rewardExp,
|
||||
));
|
||||
|
||||
// 同时推一条 MessageSent 消息显示在聊天窗口
|
||||
$msg = [
|
||||
'id' => app(\App\Services\ChatStateService::class)->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '星海小博士',
|
||||
'to_user' => '大家',
|
||||
'content' => "🧩 猜成语时间!{$idiom->hint}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#7c3aed',
|
||||
'action' => '',
|
||||
'idiom_game_round_id' => $round->id,
|
||||
'idiom_reward_gold' => $rewardGold,
|
||||
'idiom_reward_exp' => $rewardExp,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
app(\App\Services\ChatStateService::class)->pushMessage($roomId, $msg);
|
||||
broadcast(new \App\Events\MessageSent($roomId, $msg));
|
||||
// 出题、过期结算、房间范围与题型独立判定统一交给服务层处理。
|
||||
app(\App\Services\RiddleGameService::class)->autoStartEligibleRounds();
|
||||
})->everyMinute()->name('idiom:auto-start')->withoutOverlapping();
|
||||
|
||||
// 每日 18:00:超级期预热广播(若当前期次为超级期,提醒用户购票)
|
||||
|
||||
+28
-13
@@ -292,11 +292,18 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
->middleware('throttle:chat-send')
|
||||
->name('chat.pat');
|
||||
|
||||
// 猜成语游戏
|
||||
// 猜谜活动游戏
|
||||
Route::prefix('riddle-quiz')->name('riddle-quiz.')->group(function () {
|
||||
Route::post('/start', [\App\Http\Controllers\RiddleQuizController::class, 'start'])->name('start');
|
||||
Route::post('/answer', [\App\Http\Controllers\RiddleQuizController::class, 'answer'])->name('answer');
|
||||
Route::get('/current', [\App\Http\Controllers\RiddleQuizController::class, 'current'])->name('current');
|
||||
});
|
||||
|
||||
// 兼容旧前端与历史脚本,暂时保留旧的 idiom-quiz 路由别名。
|
||||
Route::prefix('idiom-quiz')->name('idiom-quiz.')->group(function () {
|
||||
Route::post('/start', [\App\Http\Controllers\IdiomQuizController::class, 'start'])->name('start');
|
||||
Route::post('/answer', [\App\Http\Controllers\IdiomQuizController::class, 'answer'])->name('answer');
|
||||
Route::get('/current', [\App\Http\Controllers\IdiomQuizController::class, 'current'])->name('current');
|
||||
Route::post('/start', [\App\Http\Controllers\RiddleQuizController::class, 'start'])->name('start');
|
||||
Route::post('/answer', [\App\Http\Controllers\RiddleQuizController::class, 'answer'])->name('answer');
|
||||
Route::get('/current', [\App\Http\Controllers\RiddleQuizController::class, 'current'])->name('current');
|
||||
});
|
||||
|
||||
// 挂机心跳存点 (限制每分钟最多调用 6 次防止挂机脚本滥用)
|
||||
@@ -590,15 +597,23 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
||||
Route::delete('/{fishing}', [\App\Http\Controllers\Admin\FishingEventController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// ── 猜成语题库 ──
|
||||
Route::prefix('idioms')->name('idioms.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\IdiomController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Admin\IdiomController::class, 'store'])->name('store');
|
||||
Route::put('/{idiom}', [\App\Http\Controllers\Admin\IdiomController::class, 'update'])->name('update');
|
||||
Route::post('/{idiom}/toggle', [\App\Http\Controllers\Admin\IdiomController::class, 'toggle'])->name('toggle');
|
||||
Route::delete('/{idiom}', [\App\Http\Controllers\Admin\IdiomController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/settings', [\App\Http\Controllers\Admin\IdiomController::class, 'saveSettings'])->name('settings.save');
|
||||
});
|
||||
// ── 猜谜活动题库 ──
|
||||
Route::prefix('riddles')->name('riddles.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\RiddleController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Admin\RiddleController::class, 'store'])->name('store');
|
||||
Route::put('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'update'])->name('update');
|
||||
Route::post('/{idiom}/toggle', [\App\Http\Controllers\Admin\RiddleController::class, 'toggle'])->name('toggle');
|
||||
Route::delete('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 兼容旧后台路径与书签,先保留 idioms 别名入口。
|
||||
Route::prefix('idioms')->name('idioms.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\RiddleController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Admin\RiddleController::class, 'store'])->name('store');
|
||||
Route::put('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'update'])->name('update');
|
||||
Route::post('/{idiom}/toggle', [\App\Http\Controllers\Admin\RiddleController::class, 'toggle'])->name('toggle');
|
||||
Route::delete('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语控制器与后台参数测试
|
||||
*
|
||||
* 覆盖猜成语后台过期时间配置、手动出题清理超时回合、
|
||||
* 以及超时后禁止继续答题的关键行为。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Idiom;
|
||||
use App\Models\IdiomGameRound;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证猜成语后台配置与回合过期逻辑。
|
||||
*/
|
||||
class IdiomQuizControllerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 方法功能:为本组测试准备隔离表结构,不触碰本地业务数据库。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->rebuildTestingTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证后台可以保存题目过期时间配置。
|
||||
*/
|
||||
public function test_admin_can_save_idiom_expire_minutes_setting(): void
|
||||
{
|
||||
$this->withoutMiddleware();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
Room::create(['room_name' => '测试房间']);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.idioms.settings.save'), [
|
||||
'reward_gold' => 88,
|
||||
'reward_exp' => 66,
|
||||
'auto_start_interval' => 9,
|
||||
'expire_minutes' => 7,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.idioms.index'));
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
$config = GameConfig::query()->where('game_key', 'idiom')->firstOrFail();
|
||||
|
||||
$this->assertSame(88, $config->params['reward_gold']);
|
||||
$this->assertSame(66, $config->params['reward_exp']);
|
||||
$this->assertSame(9, $config->params['auto_start_interval']);
|
||||
$this->assertSame(7, $config->params['expire_minutes']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证后台会拦截负数的题目过期时间。
|
||||
*/
|
||||
public function test_admin_cannot_save_negative_idiom_expire_minutes_setting(): void
|
||||
{
|
||||
$this->withoutMiddleware();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
Room::create(['room_name' => '测试房间']);
|
||||
|
||||
$response = $this->from(route('admin.idioms.index'))
|
||||
->actingAs($admin)
|
||||
->post(route('admin.idioms.settings.save'), [
|
||||
'reward_gold' => 88,
|
||||
'reward_exp' => 66,
|
||||
'auto_start_interval' => 9,
|
||||
'expire_minutes' => -1,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.idioms.index'));
|
||||
$response->assertSessionHasErrors('expire_minutes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证手动出题前会清理已超时的旧回合,避免阻塞新题。
|
||||
*/
|
||||
public function test_start_clears_expired_round_before_creating_new_round(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
$room = Room::create(['room_name' => '猜成语房间']);
|
||||
$idiom = $this->createActiveIdiom();
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜成语',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$expiredRound = IdiomGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'started_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('idiom-quiz.start'), [
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
$expiredRound->refresh();
|
||||
|
||||
$this->assertSame('ended', $expiredRound->status);
|
||||
$this->assertNotNull($expiredRound->ended_at);
|
||||
$this->assertDatabaseCount('idiom_game_rounds', 2);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $room->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证回合超时后不能继续答题,也不会发放奖励。
|
||||
*/
|
||||
public function test_answer_rejects_expired_round_and_marks_it_ended(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$player = User::factory()->create([
|
||||
'username' => '答题用户',
|
||||
'exp_num' => 10,
|
||||
]);
|
||||
$room = Room::create(['room_name' => '答题房间']);
|
||||
$idiom = $this->createActiveIdiom();
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜成语',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 20,
|
||||
'reward_exp' => 15,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$round = IdiomGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 20,
|
||||
'reward_exp' => 15,
|
||||
'started_at' => now()->subMinutes(6),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($player)->postJson(route('idiom-quiz.answer'), [
|
||||
'round_id' => $round->id,
|
||||
'room_id' => $room->id,
|
||||
'answer' => '画蛇添足',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJsonPath('status', 'error')
|
||||
->assertJsonPath('message', '该回合已超时结束');
|
||||
|
||||
$round->refresh();
|
||||
$player->refresh();
|
||||
|
||||
$this->assertSame('ended', $round->status);
|
||||
$this->assertNotNull($round->ended_at);
|
||||
$this->assertNull($round->winner_id);
|
||||
$this->assertSame(10, $player->exp_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建站长账号,满足后台权限与手动出题权限要求。
|
||||
*/
|
||||
private function createSiteOwner(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'id' => 1,
|
||||
'user_level' => 100,
|
||||
'username' => '站长',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建一条可用于猜成语测试的启用题目。
|
||||
*/
|
||||
private function createActiveIdiom(): Idiom
|
||||
{
|
||||
return Idiom::create([
|
||||
'answer' => '画蛇添足',
|
||||
'hint' => '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
|
||||
'sort' => 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:重建本组测试所需的最小表结构。
|
||||
*/
|
||||
private function rebuildTestingTables(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('idiom_game_rounds');
|
||||
Schema::dropIfExists('idioms');
|
||||
Schema::dropIfExists('game_configs');
|
||||
Schema::dropIfExists('rooms');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('username')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->string('remember_token', 100)->nullable();
|
||||
$table->unsignedTinyInteger('sex')->default(1);
|
||||
$table->text('custom_join_message')->nullable();
|
||||
$table->text('custom_leave_message')->nullable();
|
||||
$table->integer('user_level')->default(1);
|
||||
$table->integer('exp_num')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('rooms', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('room_name')->nullable();
|
||||
$table->string('room_owner')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('game_configs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('game_key')->unique();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('enabled')->default(false);
|
||||
$table->json('params')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('idioms', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('answer', 50);
|
||||
$table->string('hint', 255);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->integer('sort')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('idiom_game_rounds', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('room_id');
|
||||
$table->unsignedBigInteger('idiom_id');
|
||||
$table->string('status', 20)->default('pending');
|
||||
$table->integer('reward_gold')->default(0);
|
||||
$table->integer('reward_exp')->default(0);
|
||||
$table->unsignedBigInteger('winner_id')->nullable();
|
||||
$table->string('winner_username', 50)->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('ended_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:替换聊天室状态服务,避免测试依赖真实 Redis。
|
||||
*/
|
||||
private function mockChatStateService(): void
|
||||
{
|
||||
$chatStateService = \Mockery::mock(ChatStateService::class);
|
||||
$chatStateService->shouldReceive('nextMessageId')->andReturn(1);
|
||||
$chatStateService->shouldReceive('pushMessage')->andReturnNull();
|
||||
|
||||
$this->app->instance(ChatStateService::class, $chatStateService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语控制器与后台参数测试
|
||||
*
|
||||
* 覆盖猜成语后台过期时间配置、手动出题清理超时回合、
|
||||
* 以及超时后禁止继续答题的关键行为。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Riddle;
|
||||
use App\Models\RiddleGameRound;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\RiddleGameService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证猜成语后台配置与回合过期逻辑。
|
||||
*/
|
||||
class RiddleQuizControllerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 方法功能:为本组测试准备隔离表结构,不触碰本地业务数据库。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->rebuildTestingTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证后台可以保存题目过期时间配置。
|
||||
*/
|
||||
public function test_service_uses_legacy_idiom_config_for_brain_teaser_scope_and_interval(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => '测试房间']);
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 88,
|
||||
'reward_exp' => 66,
|
||||
'auto_start_interval' => 9,
|
||||
'expire_minutes' => 7,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$room->id],
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var RiddleGameService $service */
|
||||
$service = $this->app->make(RiddleGameService::class);
|
||||
|
||||
$this->assertSame(9, $service->getAutoStartInterval(Riddle::TYPE_BRAIN_TEASER));
|
||||
$this->assertSame(7, $service->getExpireMinutes(Riddle::TYPE_BRAIN_TEASER));
|
||||
$this->assertSame([$room->id], $service->getScopedRoomIds(Riddle::TYPE_BRAIN_TEASER));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证 single 房间范围模式只取配置中的首个房间。
|
||||
*/
|
||||
public function test_service_only_uses_first_room_when_scope_mode_is_single(): void
|
||||
{
|
||||
$roomOne = Room::create(['room_name' => '测试房间1']);
|
||||
$roomTwo = Room::create(['room_name' => '测试房间2']);
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 3,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$roomOne->id, $roomTwo->id],
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var RiddleGameService $service */
|
||||
$service = $this->app->make(RiddleGameService::class);
|
||||
|
||||
$this->assertSame([$roomOne->id], $service->getScopedRoomIds(Riddle::TYPE_IDIOM));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证手动出题前会清理已超时的旧回合,避免阻塞新题。
|
||||
*/
|
||||
public function test_start_clears_expired_round_before_creating_new_round(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
$room = Room::create(['room_name' => '猜成语房间']);
|
||||
$idiom = $this->createActiveIdiom();
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜成语',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$room->id],
|
||||
],
|
||||
]);
|
||||
|
||||
$expiredRound = RiddleGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'started_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
$expiredRound->refresh();
|
||||
|
||||
$this->assertSame('ended', $expiredRound->status);
|
||||
$this->assertNotNull($expiredRound->ended_at);
|
||||
$this->assertDatabaseCount('idiom_game_rounds', 2);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $room->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证旧路由在传入脑筋急转弯题型时也能正常手动开题。
|
||||
*/
|
||||
public function test_start_supports_brain_teaser_quiz_type_on_legacy_route(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
$room = Room::create(['room_name' => '猜谜房间']);
|
||||
$brainTeaser = $this->createQuestion(
|
||||
type: Riddle::TYPE_BRAIN_TEASER,
|
||||
answer: '影子',
|
||||
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
|
||||
);
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 66,
|
||||
'reward_exp' => 44,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$room->id],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
|
||||
'room_id' => $room->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER)
|
||||
->assertJsonPath('data.hint', $brainTeaser->hint);
|
||||
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $brainTeaser->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证手动出题会结束当前同题型旧回合并直接开启新题。
|
||||
*/
|
||||
public function test_start_manually_ends_previous_active_round_of_same_quiz_type(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
$room = Room::create(['room_name' => '手动出题房间']);
|
||||
$questionOne = $this->createQuestion(
|
||||
type: Riddle::TYPE_BRAIN_TEASER,
|
||||
answer: '影子',
|
||||
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
|
||||
);
|
||||
$questionTwo = $this->createQuestion(
|
||||
type: Riddle::TYPE_BRAIN_TEASER,
|
||||
answer: '回声',
|
||||
hint: '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?',
|
||||
);
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 66,
|
||||
'reward_exp' => 44,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$room->id],
|
||||
],
|
||||
]);
|
||||
|
||||
$oldRound = RiddleGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $questionOne->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 66,
|
||||
'reward_exp' => 44,
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
|
||||
'room_id' => $room->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER);
|
||||
|
||||
$oldRound->refresh();
|
||||
|
||||
$this->assertSame('ended', $oldRound->status);
|
||||
$this->assertNotNull($oldRound->ended_at);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $room->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->assertSame(2, RiddleGameRound::query()->where('room_id', $room->id)->count());
|
||||
$this->assertSame(1, RiddleGameRound::query()
|
||||
->where('room_id', $room->id)
|
||||
->where('quiz_type', Riddle::TYPE_BRAIN_TEASER)
|
||||
->where('status', 'active')
|
||||
->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证游戏总开关关闭时返回明确提示,而不是误报题库为空。
|
||||
*/
|
||||
public function test_start_returns_clear_message_when_riddle_activity_is_disabled(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
$room = Room::create(['room_name' => '猜谜房间']);
|
||||
$this->createQuestion(
|
||||
type: Riddle::TYPE_BRAIN_TEASER,
|
||||
answer: '影子',
|
||||
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
|
||||
);
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'enabled' => false,
|
||||
'params' => [
|
||||
'reward_gold' => 66,
|
||||
'reward_exp' => 44,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$room->id],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
|
||||
'room_id' => $room->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJsonPath('status', 'error')
|
||||
->assertJsonPath('message', '猜谜活动未开启,请先到游戏管理中开启后再出题。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证回合超时后不能继续答题,也不会发放奖励。
|
||||
*/
|
||||
public function test_answer_rejects_expired_round_and_marks_it_ended(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$player = User::factory()->create([
|
||||
'username' => '答题用户',
|
||||
'exp_num' => 10,
|
||||
]);
|
||||
$room = Room::create(['room_name' => '答题房间']);
|
||||
$idiom = $this->createActiveIdiom();
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜成语',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 20,
|
||||
'reward_exp' => 15,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'single',
|
||||
'room_ids' => [$room->id],
|
||||
],
|
||||
]);
|
||||
|
||||
$round = RiddleGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 20,
|
||||
'reward_exp' => 15,
|
||||
'started_at' => now()->subMinutes(6),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [
|
||||
'round_id' => $round->id,
|
||||
'room_id' => $room->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'answer' => '画蛇添足',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJsonPath('status', 'error')
|
||||
->assertJsonPath('message', '该回合已超时结束');
|
||||
|
||||
$round->refresh();
|
||||
$player->refresh();
|
||||
|
||||
$this->assertSame('ended', $round->status);
|
||||
$this->assertNotNull($round->ended_at);
|
||||
$this->assertNull($round->winner_id);
|
||||
$this->assertSame(10, $player->exp_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证自动出题会按房间和题型两个维度独立判断。
|
||||
*/
|
||||
public function test_auto_start_checks_room_and_quiz_type_independently(): void
|
||||
{
|
||||
$this->mockChatStateService();
|
||||
|
||||
$roomOne = Room::create(['room_name' => '一号房']);
|
||||
$roomTwo = Room::create(['room_name' => '二号房']);
|
||||
$idiom = $this->createQuestion(
|
||||
type: Riddle::TYPE_IDIOM,
|
||||
answer: '画蛇添足',
|
||||
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
|
||||
);
|
||||
$brainTeaser = $this->createQuestion(
|
||||
type: Riddle::TYPE_BRAIN_TEASER,
|
||||
answer: '影子',
|
||||
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
|
||||
);
|
||||
|
||||
GameConfig::create([
|
||||
'game_key' => 'idiom',
|
||||
'name' => '猜谜活动',
|
||||
'icon' => '🧩',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'reward_gold' => 30,
|
||||
'reward_exp' => 18,
|
||||
'auto_start_interval' => 1,
|
||||
'expire_minutes' => 5,
|
||||
'room_scope_mode' => 'multiple',
|
||||
'room_ids' => [$roomOne->id, $roomTwo->id],
|
||||
],
|
||||
]);
|
||||
|
||||
// 一号房已有进行中的成语题,只允许系统补开脑筋急转弯题。
|
||||
RiddleGameRound::create([
|
||||
'room_id' => $roomOne->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 30,
|
||||
'reward_exp' => 18,
|
||||
'started_at' => now()->subSeconds(30),
|
||||
]);
|
||||
|
||||
/** @var RiddleGameService $service */
|
||||
$service = $this->app->make(RiddleGameService::class);
|
||||
$startedCount = $service->autoStartEligibleRounds();
|
||||
|
||||
$this->assertSame(3, $startedCount);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $roomOne->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $roomOne->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
'idiom_id' => $brainTeaser->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $roomTwo->id,
|
||||
'quiz_type' => Riddle::TYPE_IDIOM,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$this->assertDatabaseHas('idiom_game_rounds', [
|
||||
'room_id' => $roomTwo->id,
|
||||
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
|
||||
'idiom_id' => $brainTeaser->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建站长账号,满足后台权限与手动出题权限要求。
|
||||
*/
|
||||
private function createSiteOwner(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'id' => 1,
|
||||
'user_level' => 100,
|
||||
'username' => '站长',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建一条可用于猜成语测试的启用题目。
|
||||
*/
|
||||
private function createActiveIdiom(): Riddle
|
||||
{
|
||||
return $this->createQuestion(
|
||||
type: Riddle::TYPE_IDIOM,
|
||||
answer: '画蛇添足',
|
||||
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:按指定题型创建一条启用中的测试题目。
|
||||
*/
|
||||
private function createQuestion(string $type, string $answer, string $hint): Riddle
|
||||
{
|
||||
return Riddle::create([
|
||||
'type' => $type,
|
||||
'answer' => $answer,
|
||||
'hint' => $hint,
|
||||
'sort' => 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:重建本组测试所需的最小表结构。
|
||||
*/
|
||||
private function rebuildTestingTables(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('idiom_game_rounds');
|
||||
Schema::dropIfExists('idioms');
|
||||
Schema::dropIfExists('game_configs');
|
||||
Schema::dropIfExists('rooms');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('username')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->string('remember_token', 100)->nullable();
|
||||
$table->unsignedTinyInteger('sex')->default(1);
|
||||
$table->text('custom_join_message')->nullable();
|
||||
$table->text('custom_leave_message')->nullable();
|
||||
$table->integer('user_level')->default(1);
|
||||
$table->integer('exp_num')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('rooms', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('room_name')->nullable();
|
||||
$table->string('room_owner')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('game_configs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('game_key')->unique();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('enabled')->default(false);
|
||||
$table->json('params')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('idioms', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('type', 30)->default(Riddle::TYPE_IDIOM);
|
||||
$table->string('answer', 50);
|
||||
$table->string('hint', 255);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->integer('sort')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('idiom_game_rounds', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('room_id');
|
||||
$table->unsignedBigInteger('idiom_id');
|
||||
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM);
|
||||
$table->string('status', 20)->default('pending');
|
||||
$table->integer('reward_gold')->default(0);
|
||||
$table->integer('reward_exp')->default(0);
|
||||
$table->unsignedBigInteger('winner_id')->nullable();
|
||||
$table->string('winner_username', 50)->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('ended_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:替换聊天室状态服务,避免测试依赖真实 Redis。
|
||||
*/
|
||||
private function mockChatStateService(): void
|
||||
{
|
||||
$chatStateService = \Mockery::mock(ChatStateService::class);
|
||||
$chatStateService->shouldReceive('nextMessageId')->andReturn(1);
|
||||
$chatStateService->shouldReceive('pushMessage')->andReturnNull();
|
||||
|
||||
$this->app->instance(ChatStateService::class, $chatStateService);
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,33 @@ class UserControllerTest extends TestCase
|
||||
], $user->chat_preferences);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试猜谜活动新文案会兼容落回旧的屏蔽键。
|
||||
*/
|
||||
public function test_quiz_activity_block_preference_is_normalized_to_legacy_sender_key(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'chat_preferences' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->putJson('/user/chat-preferences', [
|
||||
'blocked_system_senders' => ['猜谜活动', '钓鱼播报'],
|
||||
'sound_muted' => false,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('data.blocked_system_senders.0', '猜成语')
|
||||
->assertJsonPath('data.blocked_system_senders.1', '钓鱼播报')
|
||||
->assertJsonPath('data.sound_muted', false);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertEquals([
|
||||
'blocked_system_senders' => ['猜成语', '钓鱼播报'],
|
||||
'sound_muted' => false,
|
||||
], $user->chat_preferences);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试合法聊天室当日状态可以保存,并在当天结束后自动失效。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user