From 5962d6d2b375d60c581b56665d4e6cd5c078344a Mon Sep 17 00:00:00 2001 From: pllx Date: Wed, 29 Apr 2026 10:32:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=8C=9C=E6=88=90=E8=AF=AD?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E4=B8=8E=E7=AD=94=E9=A2=98=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Admin/IdiomController.php | 22 +- app/Http/Controllers/IdiomQuizController.php | 62 ++-- app/Models/IdiomGameRound.php | 16 + app/Services/IdiomGameService.php | 124 ++++++++ database/seeders/IdiomSeeder.php | 2 + resources/js/chat-room/chat-events.js | 35 -- resources/js/chat-room/idiom-quiz.js | 182 ++++++++--- resources/js/chat-room/message-renderer.js | 44 ++- resources/views/admin/idioms/index.blade.php | 7 + routes/console.php | 7 +- tests/Feature/IdiomQuizControllerTest.php | 299 ++++++++++++++++++ 11 files changed, 685 insertions(+), 115 deletions(-) create mode 100644 app/Services/IdiomGameService.php create mode 100644 tests/Feature/IdiomQuizControllerTest.php diff --git a/app/Http/Controllers/Admin/IdiomController.php b/app/Http/Controllers/Admin/IdiomController.php index 52ca264..e3bf78f 100644 --- a/app/Http/Controllers/Admin/IdiomController.php +++ b/app/Http/Controllers/Admin/IdiomController.php @@ -12,16 +12,20 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\GameConfig; use App\Models\Idiom; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +/** + * 类功能:负责猜成语题库与后台参数管理。 + */ class IdiomController extends Controller { /** - * 显示所有成语题目列表 + * 方法功能:显示所有成语题目列表。 */ public function index(): View { @@ -31,7 +35,7 @@ class IdiomController extends Controller } /** - * 创建新题目 + * 方法功能:创建新的成语题目。 */ public function store(Request $request): RedirectResponse { @@ -49,7 +53,7 @@ class IdiomController extends Controller } /** - * 更新题目 + * 方法功能:更新已有成语题目。 */ public function update(Request $request, Idiom $idiom): RedirectResponse { @@ -67,7 +71,7 @@ class IdiomController extends Controller } /** - * 切换启用/禁用(AJAX) + * 方法功能:通过 AJAX 切换题目的启用状态。 */ public function toggle(Idiom $idiom): JsonResponse { @@ -81,7 +85,7 @@ class IdiomController extends Controller } /** - * 删除题目 + * 方法功能:删除指定成语题目。 */ public function destroy(Idiom $idiom): RedirectResponse { @@ -92,7 +96,7 @@ class IdiomController extends Controller } /** - * 保存猜成语游戏参数(仅更新 GameConfig params,不影响其他字段) + * 方法功能:保存猜成语游戏参数而不覆盖其他游戏配置字段。 */ public function saveSettings(Request $request): RedirectResponse { @@ -100,19 +104,21 @@ class IdiomController extends Controller '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 = \App\Models\GameConfig::firstOrCreate( + $config = GameConfig::firstOrCreate( ['game_key' => 'idiom'], ['name' => '猜成语', 'icon' => '🧩', 'enabled' => false], ); - // 合并现有 params,只覆盖提交的字段,不影响其他已有参数 + // 合并现有 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(); diff --git a/app/Http/Controllers/IdiomQuizController.php b/app/Http/Controllers/IdiomQuizController.php index b80f4be..c0f9bef 100644 --- a/app/Http/Controllers/IdiomQuizController.php +++ b/app/Http/Controllers/IdiomQuizController.php @@ -18,15 +18,23 @@ use App\Models\GameConfig; use App\Models\Idiom; use App\Models\IdiomGameRound; use App\Services\ChatStateService; +use App\Services\IdiomGameService; use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +/** + * 类功能:处理猜成语出题、答题和当前回合查询。 + */ class IdiomQuizController extends Controller { + /** + * 方法功能:注入猜成语所需的状态、结算与奖励服务。 + */ public function __construct( private readonly ChatStateService $chatState, + private readonly IdiomGameService $idiomGameService, private readonly UserCurrencyService $currencyService, ) {} @@ -37,7 +45,7 @@ class IdiomQuizController extends Controller { $user = Auth::user(); - // 权限校验:仅站长或 superlevel + // 权限校验:仅站长或具备后台身份的管理用户可手动出题。 if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) { return response()->json(['status' => 'error', 'message' => '无权限'], 403); } @@ -47,7 +55,10 @@ class IdiomQuizController extends Controller return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422); } - // 检查是否有进行中的回合 + // 先清理该房间已超时但未结算的旧回合,避免它们长期卡住新题。 + $this->idiomGameService->expireActiveRoundsForRoom($roomId); + + // 清理后再检查是否还有真正进行中的回合。 $activeRound = IdiomGameRound::where('room_id', $roomId) ->whereIn('status', ['pending', 'active']) ->first(); @@ -64,13 +75,13 @@ class IdiomQuizController extends Controller 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, @@ -80,7 +91,7 @@ class IdiomQuizController extends Controller 'started_at' => now(), ]); - // 广播到聊天室 + // 广播到聊天室,让前端即时展示题目提示与答题按钮。 broadcast(new IdiomGameStarted( roomId: $roomId, hint: $idiom->hint, @@ -89,7 +100,7 @@ class IdiomQuizController extends Controller rewardExp: $rewardExp, )); - // 同时也推一条 MessageSent 消息(显示在聊天窗口) + // 同时推一条公屏消息,兼容现有聊天窗口的消息渲染链路。 $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, @@ -119,10 +130,7 @@ class IdiomQuizController extends Controller } /** - * 提交答案(POST) - * - * @param Request $request - * @return JsonResponse + * 方法功能:提交当前猜成语回合的答案。 */ public function answer(Request $request): JsonResponse { @@ -145,6 +153,11 @@ class IdiomQuizController extends Controller 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([ @@ -152,10 +165,11 @@ class IdiomQuizController extends Controller '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)) { @@ -165,7 +179,7 @@ class IdiomQuizController extends Controller ], 200); } - // 答对了!加锁防并发(Redis) + // 答对后立即加 Redis 锁,防止多人并发提交造成重复领奖。 $lockKey = "idiom:answer_lock:{$roundId}"; if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) { return response()->json([ @@ -175,7 +189,7 @@ class IdiomQuizController extends Controller } \Illuminate\Support\Facades\Redis::expire($lockKey, 10); - // 更新回合状态 + // 抢答成功后立刻封盘,确保后续请求统一看到 answered 状态。 $round->update([ 'status' => 'answered', 'winner_id' => $user->id, @@ -183,7 +197,7 @@ class IdiomQuizController extends Controller 'ended_at' => now(), ]); - // 发放奖励 + // 奖励仍沿用现有金币与经验发放逻辑,避免行为回归。 if ($round->reward_gold > 0) { $this->currencyService->change( $user, 'gold', $round->reward_gold, @@ -197,7 +211,7 @@ class IdiomQuizController extends Controller $user->save(); } - // 广播结果(前端通过 IdiomGameAnswered 事件做分屏显示) + // 广播结果,让房间内用户立即看到答题成功公告。 broadcast(new IdiomGameAnswered( roomId: $roomId, roundId: $round->id, @@ -207,16 +221,21 @@ class IdiomQuizController extends Controller 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} 经验!", + 'content' => "🎉 【{$user->username}】率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!", 'is_secret' => false, 'font_color' => '#16a34a', - 'action' => '', + '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); @@ -235,7 +254,7 @@ class IdiomQuizController extends Controller } /** - * 查询当前进行中的回合 + * 方法功能:查询当前房间的进行中回合。 */ public function current(Request $request): JsonResponse { @@ -253,6 +272,11 @@ class IdiomQuizController extends Controller 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' => [ diff --git a/app/Models/IdiomGameRound.php b/app/Models/IdiomGameRound.php index 7ce1c99..6b932d0 100644 --- a/app/Models/IdiomGameRound.php +++ b/app/Models/IdiomGameRound.php @@ -15,8 +15,16 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * 类功能:记录猜成语每一轮的题目、奖励与结算状态。 + */ class IdiomGameRound extends Model { + /** + * 方法功能:声明可批量赋值的回合字段。 + * + * @var array + */ protected $fillable = [ 'room_id', 'idiom_id', @@ -29,6 +37,11 @@ class IdiomGameRound extends Model 'ended_at', ]; + /** + * 方法功能:定义回合字段的类型转换规则。 + * + * @return array + */ protected function casts(): array { return [ @@ -39,6 +52,9 @@ class IdiomGameRound extends Model ]; } + /** + * 方法功能:关联本回合对应的成语题目。 + */ public function idiom(): BelongsTo { return $this->belongsTo(Idiom::class); diff --git a/app/Services/IdiomGameService.php b/app/Services/IdiomGameService.php new file mode 100644 index 0000000..bc12607 --- /dev/null +++ b/app/Services/IdiomGameService.php @@ -0,0 +1,124 @@ +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/database/seeders/IdiomSeeder.php b/database/seeders/IdiomSeeder.php index 6a3ba7e..a3ca315 100644 --- a/database/seeders/IdiomSeeder.php +++ b/database/seeders/IdiomSeeder.php @@ -7,6 +7,7 @@ * 使用 updateOrCreate 确保重复执行不影响已有数据。 * * @author ChatRoom Laravel + * * @version 1.0.0 */ @@ -35,6 +36,7 @@ class IdiomSeeder extends Seeder 'reward_gold' => 50, 'reward_exp' => 30, 'auto_start_interval' => 0, + 'expire_minutes' => 5, ], ], ); diff --git a/resources/js/chat-room/chat-events.js b/resources/js/chat-room/chat-events.js index 3814435..cfd0cbc 100644 --- a/resources/js/chat-room/chat-events.js +++ b/resources/js/chat-room/chat-events.js @@ -366,41 +366,6 @@ export function bindChatEvents() { } enqueueChatMessage(msg); - // 猜成语消息:追加【答题】按钮 - if (msg.idom_game_round_id || msg.idiom_game_round_id) { - const roundId = msg.idom_game_round_id || msg.idiom_game_round_id; - const hint = msg.content || ""; - const rewardGold = msg.idiom_reward_gold || 0; - const rewardExp = msg.idiom_reward_exp || 0; - - // 延迟等消息渲染完成再追加按钮 - setTimeout(() => { - const containers = [ - document.getElementById("chat-messages-container"), - document.getElementById("chat-messages-container2"), - ]; - containers.forEach((container) => { - if (!container) return; - const lastMsg = container.lastElementChild; - if (!lastMsg || lastMsg.querySelector("[data-idiom-answer-btn]")) return; - if (lastMsg.dataset.fromUser !== "星海小博士") return; - - 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;"; - lastMsg.appendChild(btn); - }); - }, 50); - } - if (msg.action === "vip_presence" && typeof window.showVipPresenceBanner === "function") { window.showVipPresenceBanner(msg); } diff --git a/resources/js/chat-room/idiom-quiz.js b/resources/js/chat-room/idiom-quiz.js index 52a7d9b..94d050d 100644 --- a/resources/js/chat-room/idiom-quiz.js +++ b/resources/js/chat-room/idiom-quiz.js @@ -8,6 +8,115 @@ function csrf() { let currentRoundId = 0; let currentRoomId = 0; +/** + * 为指定回合创建统一样式的答题按钮。 + */ +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) { + // 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。 + } +} + /** * 收到猜成语出题事件时,在聊天窗口显示提示消息。 */ @@ -31,6 +140,7 @@ function handleIdiomGameAnswered(e) { if (!answer) return; currentRoundId = 0; + removeIdiomAnswerButtons(round_id); // 关闭当前用户的答题弹窗(如果开着的话) const answerModal = document.getElementById("idiom-answer-modal"); @@ -38,35 +148,25 @@ function handleIdiomGameAnswered(e) { answerModal.style.display = "none"; } - // ── 分屏文字提示 ── - // 回答者 → 包厢(chat-messages-container2) - // 其他人 → 公屏(chat-messages-container) + // 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。 const now = new Date(); - const timeStr = now.getHours().toString().padStart(2, "0") + ":" + - now.getMinutes().toString().padStart(2, "0") + ":" + - now.getSeconds().toString().padStart(2, "0"); - - const div = document.createElement("div"); - div.className = "msg-line"; - - div.innerHTML = `🎉 恭喜 ${winner_username} 率先答对成语「${answer}」,获得 ${reward_gold} 金币、${reward_exp} 经验!(${timeStr})`; - - const isWinner = winner_username === (window.chatContext?.username || ""); - if (isWinner) { - // 回答者 → 包厢 - const say2 = document.getElementById("chat-messages-container2"); - if (say2) { - say2.appendChild(div.cloneNode(true)); - say2.scrollTop = say2.scrollHeight; - } - } else { - // 其他人 → 公屏 - const say1 = document.getElementById("chat-messages-container"); - if (say1) { - say1.appendChild(div); - say1.scrollTop = say1.scrollHeight; - } - } + 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({ @@ -76,15 +176,6 @@ function handleIdiomGameAnswered(e) { color: "#16a34a", duration: 6000, }); - - // ── 标记所有对应 round_id 的【答题】按钮为已答 ── - document.querySelectorAll(`[data-idiom-answer-btn="${round_id}"]`).forEach((btn) => { - btn.dataset.idiomAnswered = "1"; - btn.textContent = "✅ 已答"; - btn.style.background = "#9ca3af"; - btn.style.cursor = "default"; - btn.style.opacity = "0.6"; - }); } /** @@ -237,18 +328,6 @@ export function bindIdiomQuizControls() { const btn = e.target.closest("[data-idiom-answer-btn]"); if (!btn) return; - // 已答完的按钮不可点击 - if (btn.dataset.idiomAnswered === "1") { - window.chatToast?.show({ - title: "🧩 猜成语", - message: "这道题已被答过了,等下一题吧!", - icon: "😅", - color: "#9ca3af", - duration: 3000, - }); - return; - } - const roundId = parseInt(btn.dataset.idiomAnswerBtn || "0", 10); const hint = btn.dataset.idiomHint || ""; const rewardGold = parseInt(btn.dataset.idiomGold || "0", 10); @@ -261,7 +340,10 @@ export function bindIdiomQuizControls() { // ── 猜成语结果消息中的用户名可点击 → 打开用户名片 // 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理 - + + window.setTimeout(() => { + syncCurrentIdiomRound(); + }, 0); } // ── 挂载到 window ── diff --git a/resources/js/chat-room/message-renderer.js b/resources/js/chat-room/message-renderer.js index 55ff285..b2a33cf 100644 --- a/resources/js/chat-room/message-renderer.js +++ b/resources/js/chat-room/message-renderer.js @@ -2,6 +2,7 @@ // 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。 import { escapeHtml, normalizeSafeChatUrl } from "./html.js"; +import { attachIdiomAnswerButton, removeIdiomAnswerButtons } from "./idiom-quiz.js"; import { isExpiredChatImageMessage } from "./message-utils.js"; import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js"; import { escapePresenceText } from "./vip-presence.js"; @@ -49,6 +50,21 @@ function parseBracketUsers(content, color = "#000099") { }); } +/** + * 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。 + */ +function prunePrivateIdiomResultMessages(targetContainer, maxRecords = 3) { + if (!targetContainer) { + return; + } + + const nodes = Array.from(targetContainer.querySelectorAll('[data-idiom-result="1"]')); + while (nodes.length > maxRecords) { + const firstNode = nodes.shift(); + firstNode?.remove(); + } +} + /** * 构建聊天消息的内容 HTML。 */ @@ -172,6 +188,14 @@ export function appendMessage(msg, renderBatch = null) { const iconImg = ``; const parsedContent = parseBracketUsers(msg.content); 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} 经验!`; } else if (msg.action === "vip_presence") { const accent = msg.presence_color || "#f59e0b"; div.style.cssText = @@ -300,6 +324,12 @@ export function appendMessage(msg, renderBatch = null) { html += ` (${timeStr})`; } 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)); + } // 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。 if (shouldHideByBlock) { @@ -325,7 +355,8 @@ export function appendMessage(msg, renderBatch = null) { } // 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的 - const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username; + const isIdiomWinnerHistory = msg.action === "idiom_result" && msg.winner_username === window.chatContext?.username; + const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username || isIdiomWinnerHistory; // 存点通知标记 const isAutoSave = (msg.from_user === "系统" || msg.from_user === "") && @@ -343,12 +374,18 @@ export function appendMessage(msg, renderBatch = null) { renderBatch.privateFragment.appendChild(div); renderBatch.shouldPrunePrivate = true; renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || state.autoScroll; + if (msg.action === "idiom_result") { + renderBatch.shouldPrunePrivateIdiomResults = true; + } return; } const container2 = state.container2; if (container2) { container2.appendChild(div); pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT); + if (msg.action === "idiom_result") { + prunePrivateIdiomResultMessages(container2, 3); + } if (state.autoScroll) { container2.scrollTop = container2.scrollHeight; } @@ -398,6 +435,7 @@ export function createChatMessageRenderBatch() { privateFragment: document.createDocumentFragment(), shouldPrunePublic: false, shouldPrunePrivate: false, + shouldPrunePrivateIdiomResults: false, shouldScrollPublic: false, shouldScrollPrivate: false, }; @@ -429,6 +467,10 @@ export function commitChatMessageRenderBatch(renderBatch) { const container2 = state.container2; if (container2) pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT); } + if (renderBatch.shouldPrunePrivateIdiomResults) { + const container2 = state.container2; + if (container2) prunePrivateIdiomResultMessages(container2, 3); + } if (renderBatch.shouldScrollPublic) { const container = state.container; if (container) container.scrollTop = container.scrollHeight; diff --git a/resources/views/admin/idioms/index.blade.php b/resources/views/admin/idioms/index.blade.php index 55cb0cc..a847644 100644 --- a/resources/views/admin/idioms/index.blade.php +++ b/resources/views/admin/idioms/index.blade.php @@ -65,6 +65,13 @@ class="w-full {{ $adminListFilterInputClass }}">

0=仅手动出题

+
+ + +

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

+