diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 6a04e2a..68c9062 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -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 => '猜谜活动奖励', }; } } diff --git a/app/Events/IdiomGameAnswered.php b/app/Events/RiddleGameAnswered.php similarity index 55% rename from app/Events/IdiomGameAnswered.php rename to app/Events/RiddleGameAnswered.php index da6eaa8..c44d39b 100644 --- a/app/Events/IdiomGameAnswered.php +++ b/app/Events/RiddleGameAnswered.php @@ -1,13 +1,9 @@ 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, diff --git a/app/Events/IdiomGameStarted.php b/app/Events/RiddleGameStarted.php similarity index 52% rename from app/Events/IdiomGameStarted.php rename to app/Events/RiddleGameStarted.php index ae54350..72fec4c 100644 --- a/app/Events/IdiomGameStarted.php +++ b/app/Events/RiddleGameStarted.php @@ -1,13 +1,9 @@ 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}", ]; } } diff --git a/app/Http/Controllers/Admin/IdiomController.php b/app/Http/Controllers/Admin/IdiomController.php deleted file mode 100644 index e3bf78f..0000000 --- a/app/Http/Controllers/Admin/IdiomController.php +++ /dev/null @@ -1,128 +0,0 @@ -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', '游戏参数已保存!'); - } -} diff --git a/app/Http/Controllers/Admin/RiddleController.php b/app/Http/Controllers/Admin/RiddleController.php new file mode 100644 index 0000000..3054aa2 --- /dev/null +++ b/app/Http/Controllers/Admin/RiddleController.php @@ -0,0 +1,168 @@ +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 + */ + 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; + } +} diff --git a/app/Http/Controllers/IdiomQuizController.php b/app/Http/Controllers/IdiomQuizController.php deleted file mode 100644 index c0f9bef..0000000 --- a/app/Http/Controllers/IdiomQuizController.php +++ /dev/null @@ -1,290 +0,0 @@ -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, - ], - ]); - } -} diff --git a/app/Http/Controllers/RiddleQuizController.php b/app/Http/Controllers/RiddleQuizController.php new file mode 100644 index 0000000..493ee76 --- /dev/null +++ b/app/Http/Controllers/RiddleQuizController.php @@ -0,0 +1,267 @@ +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, + ], + ]); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index d3ea516..ab4a6f3 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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'], ]; diff --git a/app/Http/Requests/UpdateChatPreferencesRequest.php b/app/Http/Requests/UpdateChatPreferencesRequest.php index 83729d7..c1894ce 100644 --- a/app/Http/Requests/UpdateChatPreferencesRequest.php +++ b/app/Http/Requests/UpdateChatPreferencesRequest.php @@ -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'], ]; diff --git a/app/Models/Idiom.php b/app/Models/Idiom.php deleted file mode 100644 index 1601aa1..0000000 --- a/app/Models/Idiom.php +++ /dev/null @@ -1,33 +0,0 @@ - 'boolean', - 'sort' => 'integer', - ]; - } -} diff --git a/app/Models/Riddle.php b/app/Models/Riddle.php new file mode 100644 index 0000000..132b3f8 --- /dev/null +++ b/app/Models/Riddle.php @@ -0,0 +1,121 @@ + + */ + protected $fillable = [ + 'type', + 'answer', + 'hint', + 'is_active', + 'sort', + ]; + + /** + * 方法功能:定义题库字段的类型转换规则。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + 'sort' => 'integer', + ]; + } + + /** + * 方法功能:返回系统支持的全部题型。 + * + * @return array + */ + 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 + */ + 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); + } +} diff --git a/app/Models/IdiomGameRound.php b/app/Models/RiddleGameRound.php similarity index 58% rename from app/Models/IdiomGameRound.php rename to app/Models/RiddleGameRound.php index 6b932d0..8443de0 100644 --- a/app/Models/IdiomGameRound.php +++ b/app/Models/RiddleGameRound.php @@ -1,13 +1,9 @@ '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); } } diff --git a/app/Services/IdiomGameService.php b/app/Services/IdiomGameService.php deleted file mode 100644 index bc12607..0000000 --- a/app/Services/IdiomGameService.php +++ /dev/null @@ -1,124 +0,0 @@ -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)); - } -} diff --git a/app/Services/RiddleGameService.php b/app/Services/RiddleGameService.php new file mode 100644 index 0000000..94a29dc --- /dev/null +++ b/app/Services/RiddleGameService.php @@ -0,0 +1,493 @@ +} + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/database/migrations/2026_04_29_100001_upgrade_idiom_quiz_to_riddle_activity_tables.php b/database/migrations/2026_04_29_100001_upgrade_idiom_quiz_to_riddle_activity_tables.php new file mode 100644 index 0000000..945be37 --- /dev/null +++ b/database/migrations/2026_04_29_100001_upgrade_idiom_quiz_to_riddle_activity_tables.php @@ -0,0 +1,73 @@ +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'); + }); + } +}; diff --git a/database/seeders/IdiomSeeder.php b/database/seeders/RiddleSeeder.php similarity index 51% rename from database/seeders/IdiomSeeder.php rename to database/seeders/RiddleSeeder.php index a3ca315..8647e4b 100644 --- a/database/seeders/IdiomSeeder.php +++ b/database/seeders/RiddleSeeder.php @@ -1,9 +1,9 @@ '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, ], ); } diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 6b46045..bdf9a79 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -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"; diff --git a/resources/js/chat-room/chat-events.js b/resources/js/chat-room/chat-events.js index cf264a1..f4dddce 100644 --- a/resources/js/chat-room/chat-events.js +++ b/resources/js/chat-room/chat-events.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); } }); diff --git a/resources/js/chat-room/idiom-quiz.js b/resources/js/chat-room/idiom-quiz.js deleted file mode 100644 index 0cbc5db..0000000 --- a/resources/js/chat-room/idiom-quiz.js +++ /dev/null @@ -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: `${winner_username} 答对了「${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; diff --git a/resources/js/chat-room/message-renderer.js b/resources/js/chat-room/message-renderer.js index 9f4ad40..2a1f7ee 100644 --- a/resources/js/chat-room/message-renderer.js +++ b/resources/js/chat-room/message-renderer.js @@ -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 ` + + ${escapeHtml(activityLabel)} + ${escapeHtml(typeLabel)} + + `; +} + +/** + * 猜谜活动开题消息统一渲染为卡片。 + */ +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 ` +
+
🧩
+
+
${buildQuizBadgeHtml(msg)}
+
+ ${safeHint} + (${timeStr}) + +
+
+ 💰 ${quizMeta.rewardGold} 金币 + ⭐ ${quizMeta.rewardExp} 经验 +
+
+
+ `; +} + +/** + * 猜谜活动结算消息统一渲染为结果卡片。 + */ +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 ` +
+
🎉
+
+
+ ${buildQuizBadgeHtml(msg, "#16a34a")} + (${timeStr}) +
+
【${winnerHtml}】率先答对「${answerText}」
+
+ 💰 ${quizMeta.rewardGold} 金币 + ⭐ ${quizMeta.rewardExp} 经验 +
+
+
+ `; +} + /** * 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。 */ @@ -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 = `🎉 【${winnerHtml}】率先答对成语「${answerText}」,获得 ${rewardGold} 金币、${rewardExp} 经验!`; + div.dataset.quizRoundEndedId = String(quizMeta.endedRoundId || quizMeta.roundId || 0); + div.dataset.quizWinnerUsername = String(msg.winner_username || ""); + const parsedContent = parseBracketUsers(msg.content); + html = `${headImg}${clickableUser(msg.from_user, fontColor, nameClass)}:${parsedContent}`; + } 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 = ` +
+
📣
+
+
+ ${buildQuizBadgeHtml(msg, "#d97706")} + (${timeStr}) +
+
${parseBracketUsers(content, "#b45309")}
+
+
+ `; + timeStrOverride = true; + } else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) { let plainAccentContent = parseBracketUsers(msg.content); html = `🌟 ${plainAccentContent}`; } 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 中,便于取消屏蔽后立即恢复显示。 diff --git a/resources/js/chat-room/preferences-status.js b/resources/js/chat-room/preferences-status.js index 06c3df0..f519b0c 100644 --- a/resources/js/chat-room/preferences-status.js +++ b/resources/js/chat-room/preferences-status.js @@ -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 "猜成语"; } diff --git a/resources/js/chat-room/riddle-quiz.js b/resources/js/chat-room/riddle-quiz.js new file mode 100644 index 0000000..7b4a6e1 --- /dev/null +++ b/resources/js/chat-room/riddle-quiz.js @@ -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: `${winnerUsername} 答对了「${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; diff --git a/resources/js/chat.js b/resources/js/chat.js index 874d5db..1ee42c6 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -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 })); }) diff --git a/resources/views/admin/game-configs/index.blade.php b/resources/views/admin/game-configs/index.blade.php index b49d1a3..38c7ba1 100644 --- a/resources/views/admin/game-configs/index.blade.php +++ b/resources/views/admin/game-configs/index.blade.php @@ -3,6 +3,11 @@ @section('title', '游戏管理') @section('content') + @php + $riddleTypeOptions = \App\Models\Riddle::typeOptions(); + $availableRooms = \App\Models\Room::orderBy('id')->get(); + @endphp +
{{-- 页头 --}} @@ -82,7 +87,8 @@ {{-- 参数配置区域 --}}
-
+ 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 -
- @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 -
- - - @if ($meta['type'] === 'boolean') - - @elseif ($meta['type'] === 'array') - - @else - - @endif + @if ($game->game_key === 'idiom') + @php + $sharedConfig = gameRiddleSharedConfig($params); + $checkedRoomIds = collect($sharedConfig['room_ids'])->map(fn ($roomId) => (int) $roomId)->all(); + @endphp +
+
+ 猜成语与脑筋急转弯共用同一套奖励、过期时间、自动出题间隔与参与房间范围配置。 + 手动出题时再单独选择题型即可。
- @endforeach -
+ +
+
+
+
猜谜活动公共设置
+
以下参数会同时作用于猜成语与脑筋急转弯。
+
+
+ @foreach ($riddleTypeOptions as $typeLabel) + {{ $typeLabel }} + @endforeach +
+
+ +
+
+ + +
+
+ + +
+
+ + +

分钟,0 表示不过期。

+
+
+ + +

分钟,0 表示仅手动出题。

+
+
+ + +
+
+ +
+ @foreach ($availableRooms as $room) + + @endforeach +
+

单选模式下只保留一个房间,多选模式可同时勾选多个房间。

+
+
+
+
+ @else +
+ @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 +
+ + + @if ($meta['type'] === 'boolean') + + @elseif ($meta['type'] === 'array') + + @else + + @endif +
+ @endforeach +
+ @endif
+ 先选房间,再选题型,后台会按对应题型配置发题。 +
+
+ @endif + {{-- 神秘箱子:手动投放区域 --}} @if ($game->game_key === 'mystery_box')
@@ -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} + */ + 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') + +@endpush diff --git a/resources/views/admin/idioms/index.blade.php b/resources/views/admin/idioms/index.blade.php deleted file mode 100644 index a847644..0000000 --- a/resources/views/admin/idioms/index.blade.php +++ /dev/null @@ -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 - - - -
- - {{-- 页头 --}} -
-
-

🧩 猜成语题库管理

-

- 管理猜成语游戏的题目库,共 {{ $idioms->count() }} 条题目 -

-
-
- - {{-- 游戏参数 + 出题 --}} -
-
-

⚙️ 游戏参数

-
-
- @csrf -
-
- - -
-
- - -
-
- - -

0=仅手动出题

-
-
- - -

0=不过期;大于 0 时超时会自动公布答案并结束回合

-
-
-
- - | - - - -
-
-
- - {{-- 题目列表 --}} -
-
- - - - - - - - - - - - @foreach ($idioms as $item) - - - - - - - - @endforeach - -
排序成语答案谜语提示状态操作
{{ $item->sort }}{{ $item->answer }}{{ $item->hint }} - - - -
- @csrf @method('DELETE') - -
-
-
-
- - {{-- 新增题目卡片 --}} -
-
-

➕ 新增成语题目

-
-
- @csrf -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- -
- - {{-- 编辑弹窗 --}} - -@endsection - -{{-- 前端编辑/切换交互脚本 --}} -@push('scripts') - -@endpush diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 608464c..da7d6c4 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -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' }}"> 🎣 钓鱼事件 - - 🧩 猜成语题库 + + 🧩 猜谜活动题库 diff --git a/resources/views/admin/riddles/index.blade.php b/resources/views/admin/riddles/index.blade.php new file mode 100644 index 0000000..35fe2a6 --- /dev/null +++ b/resources/views/admin/riddles/index.blade.php @@ -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 + + + +
+ + +
+
+
+

🔎 题库筛选

+

支持按题型和关键词快速定位题目。

+
+
+
+
+
+ + +
+
+ + +
+
+ + 重置 +
+
+
+ @foreach ($quizTypes as $quizType => $quizLabel) + + {{ $quizLabel }}:{{ $typeStats[$quizType] ?? 0 }} 题 + + @endforeach +
+
+
+ +
+
+

📚 题目列表

+
+ +
+ + + + + + + + + + + + + + @forelse ($idioms as $item) + + + + + + + + + + @empty + + + + @endforelse + +
ID题型标准答案题面 / 提示排序状态操作
#{{ $item->id }} + + {{ \App\Models\Riddle::labelForType($item->type) }} + + {{ $item->answer }}{{ $item->hint }}{{ $item->sort }} + + + +
+ @csrf + @method('DELETE') + + + +
+
当前筛选条件下暂无题目。
+
+
+ +
+
+

➕ 新增题目

+
+
+ @csrf + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 1d6b699..10ae3f7 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -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);">
-
🧩 猜成语
+
+
🧩 猜谜活动
+ 成语题 +

-

diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 06f699d..63bf1c1 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -138,7 +138,7 @@ $welcomeMessages = [