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

This commit is contained in:
pllx
2026-04-29 13:35:20 +08:00
parent 192259f0a4
commit fe3e74b5f8
34 changed files with 3369 additions and 1833 deletions
+2 -2
View File
@@ -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";
+4 -4
View File
@@ -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);
}
});
-395
View File
@@ -1,395 +0,0 @@
// 猜成语游戏前端模块
// 监听 IdiomGameStarted / IdiomGameAnswered 事件,提供答题弹窗功能
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
let currentRoundId = 0;
let currentRoomId = 0;
/**
* 查找当前回合是否已经有对应的聊天室消息节点。
*/
function findIdiomRoundMessageNode(roundId) {
if (roundId <= 0) {
return null;
}
return document.querySelector(`[data-idiom-round-id="${roundId}"]`);
}
/**
* 为指定回合创建统一样式的答题按钮。
*/
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.idiomAnswerBtn = String(roundId);
btn.dataset.idiomHint = hint;
btn.dataset.idiomGold = String(rewardGold);
btn.dataset.idiomExp = String(rewardExp);
btn.textContent = "🎯 答题";
btn.style.cssText =
"margin-left:8px;padding:2px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
"font-weight:bold;vertical-align:middle;";
return btn;
}
/**
* 清理指定回合的所有答题按钮。
*/
export function removeIdiomAnswerButtons(roundId = 0) {
const selector = roundId > 0
? `[data-idiom-answer-btn="${roundId}"]`
: "[data-idiom-answer-btn]";
document.querySelectorAll(selector).forEach((button) => button.remove());
}
/**
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
*/
export function attachIdiomAnswerButton(messageNode, message) {
if (!messageNode || !message) {
return;
}
const roundId = Number.parseInt(
String(message.idiom_game_round_id || message.idom_game_round_id || "0"),
10,
);
if (roundId <= 0) {
return;
}
if (Number.parseInt(String(message.idiom_game_round_ended_id || "0"), 10) > 0) {
return;
}
if (message.from_user !== "星海小博士") {
return;
}
if (messageNode.querySelector(`[data-idiom-answer-btn="${roundId}"]`)) {
return;
}
const hint = String(message.content || "");
const rewardGold = Number.parseInt(String(message.idiom_reward_gold || "0"), 10);
const rewardExp = Number.parseInt(String(message.idiom_reward_exp || "0"), 10);
const button = buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp);
const timeNode = messageNode.querySelector(".msg-time");
if (timeNode?.parentNode) {
timeNode.parentNode.insertBefore(button, timeNode.nextSibling);
return;
}
messageNode.appendChild(button);
}
/**
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
*/
async function syncCurrentIdiomRound() {
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
if (roomId <= 0) {
return;
}
currentRoomId = roomId;
try {
const response = await fetch(`/idiom-quiz/current?room_id=${roomId}`, {
headers: {
Accept: "application/json",
},
});
const data = await response.json();
const activeRoundId = Number.parseInt(String(data?.data?.round_id || "0"), 10);
currentRoundId = activeRoundId;
if (activeRoundId <= 0) {
removeIdiomAnswerButtons();
return;
}
document.querySelectorAll("[data-idiom-answer-btn]").forEach((button) => {
if (button.dataset.idiomAnswerBtn !== String(activeRoundId)) {
button.remove();
}
});
} catch (_error) {
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
}
}
/**
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
*/
function handleIdiomGameStarted(e) {
const { round_id, hint, reward_gold, reward_exp, message } = e.detail || {};
if (!round_id || !hint) return;
currentRoundId = round_id;
currentRoomId = window.chatContext?.roomId || 0;
// 线上如果 MessageSent 补消息没有到达,这里主动补一条公屏消息兜底;
// 本地或正常链路下若消息已存在,则只补挂答题按钮,避免重复渲染。
const existingMessageNode = findIdiomRoundMessageNode(round_id);
if (existingMessageNode) {
attachIdiomAnswerButton(existingMessageNode, {
from_user: "星海小博士",
content: message || `🧩 猜成语时间!${hint}`,
idiom_game_round_id: round_id,
idiom_reward_gold: reward_gold,
idiom_reward_exp: reward_exp,
});
console.log(`猜成语开始:${hint},奖励 ${reward_gold}金/${reward_exp}经验`);
return;
}
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `idiom-start-live-${round_id}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "星海小博士",
to_user: "大家",
content: message || `🧩 猜成语时间!${hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
idiom_game_round_id: round_id,
idiom_reward_gold: reward_gold,
idiom_reward_exp: reward_exp,
sent_at: timeStr,
});
console.log(`猜成语开始:${hint},奖励 ${reward_gold}金/${reward_exp}经验`);
}
/**
* 收到猜成语结果事件。
*/
function handleIdiomGameAnswered(e) {
const { answer, winner_username, reward_gold, reward_exp, round_id } = e.detail || {};
if (!answer) return;
currentRoundId = 0;
removeIdiomAnswerButtons(round_id);
// 关闭当前用户的答题弹窗(如果开着的话)
const answerModal = document.getElementById("idiom-answer-modal");
if (answerModal && answerModal.style.display !== "none") {
answerModal.style.display = "none";
}
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `idiom-result-live-${round_id}-${Date.now()}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "星海小博士",
to_user: "大家",
content: `🎉 【${winner_username}】率先答对成语「${answer}」,获得 ${reward_gold} 金币、${reward_exp} 经验!`,
is_secret: false,
font_color: "#16a34a",
action: "idiom_result",
winner_username,
idiom_answer: answer,
idiom_result_reward_gold: reward_gold,
idiom_result_reward_exp: reward_exp,
idiom_game_round_ended_id: round_id,
sent_at: timeStr,
});
// ── Toast 通知(所有用户都能看到) ──
window.chatToast?.show({
title: "🧩 猜成语",
message: `<b>${winner_username}</b> 答对了「${answer}」,获得 ${reward_gold}💰 + ${reward_exp}⭐!`,
icon: "🎉",
color: "#16a34a",
duration: 6000,
});
}
/**
* 打开答题弹窗。
*/
function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp) {
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
const modal = document.getElementById("idiom-answer-modal");
if (!modal) return;
const hintEl = document.getElementById("idiom-answer-hint");
const rewardEl = document.getElementById("idiom-answer-reward");
if (hintEl) hintEl.textContent = hint;
if (rewardEl) rewardEl.textContent = `🎁 答对奖励:${rewardGold} 金币 + ${rewardExp} 经验`;
modal.style.display = "flex";
const input = document.getElementById("idiom-answer-input");
if (input) {
input.value = "";
input.focus();
input.disabled = false;
}
const submitBtn = document.getElementById("idiom-answer-submit");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
const feedbackEl = document.getElementById("idiom-answer-feedback");
if (feedbackEl) feedbackEl.textContent = "";
}
/**
* 关闭答题弹窗。
*/
function closeIdiomAnswerModal() {
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.style.display = "none";
}
/**
* 提交答案。
*/
async function submitIdiomAnswer() {
const input = document.getElementById("idiom-answer-input");
const feedbackEl = document.getElementById("idiom-answer-feedback");
const submitBtn = document.getElementById("idiom-answer-submit");
if (!input || !feedbackEl || !submitBtn) return;
const answer = input.value.trim();
if (!answer) {
feedbackEl.textContent = "请输入成语答案";
feedbackEl.style.color = "#ef4444";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中...";
try {
const response = await fetch("/idiom-quiz/answer", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
round_id: currentRoundId,
answer: answer,
room_id: currentRoomId,
}),
});
const data = await response.json();
if (data.status === "success") {
feedbackEl.textContent = data.message || "🎉 回答正确!";
feedbackEl.style.color = "#16a34a";
input.disabled = true;
// 延迟关闭弹窗
setTimeout(() => {
closeIdiomAnswerModal();
}, 2000);
} else {
feedbackEl.textContent = data.message || "答案不正确";
feedbackEl.style.color = "#ef4444";
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
input.focus();
input.select();
}
} catch (error) {
feedbackEl.textContent = "网络错误,请稍后重试";
feedbackEl.style.color = "#ef4444";
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
}
// ── 事件绑定 ──
export function bindIdiomQuizControls() {
// 已经绑定的不再重复绑定
if (document.getElementById("idiom-answer-modal")?.dataset?.idiomBound) return;
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.dataset.idiomBound = "1";
// 关闭按钮
document.addEventListener("click", (e) => {
const closeBtn = e.target.closest("[data-idiom-answer-close]");
if (closeBtn) {
closeIdiomAnswerModal();
return;
}
// 点击遮罩层关闭
const overlay = e.target.closest("#idiom-answer-modal");
if (overlay && e.target === overlay) {
closeIdiomAnswerModal();
}
});
// 提交按钮
document.addEventListener("click", (e) => {
const submitBtn = e.target.closest("[data-idiom-answer-submit]");
if (submitBtn) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 输入框 Enter 提交
document.addEventListener("keydown", (e) => {
const input = e.target.closest("#idiom-answer-input");
if (input && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 聊天消息中的【答题】按钮点击
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-idiom-answer-btn]");
if (!btn) return;
const roundId = parseInt(btn.dataset.idiomAnswerBtn || "0", 10);
const hint = btn.dataset.idiomHint || "";
const rewardGold = parseInt(btn.dataset.idiomGold || "0", 10);
const rewardExp = parseInt(btn.dataset.idiomExp || "0", 10);
if (roundId > 0) {
openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp);
}
});
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
window.setTimeout(() => {
syncCurrentIdiomRound();
}, 0);
}
// ── 挂载到 window ──
window.openIdiomAnswerModal = openIdiomAnswerModal;
window.closeIdiomAnswerModal = closeIdiomAnswerModal;
window.submitIdiomAnswer = submitIdiomAnswer;
window.handleIdiomGameStarted = handleIdiomGameStarted;
window.handleIdiomGameAnswered = handleIdiomGameAnswered;
+114 -19
View File
@@ -2,7 +2,13 @@
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { attachIdiomAnswerButton, removeIdiomAnswerButtons } from "./idiom-quiz.js";
import {
attachIdiomAnswerButton,
buildQuizActivityTitle,
disableIdiomAnswerButtons,
isQuizStartMessage,
normalizeQuizRoundPayload,
} from "./riddle-quiz.js";
import { isExpiredChatImageMessage } from "./message-utils.js";
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
import { escapePresenceText } from "./vip-presence.js";
@@ -50,6 +56,80 @@ function parseBracketUsers(content, color = "#000099") {
});
}
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
const { activityLabel, typeLabel } = buildQuizActivityTitle(msg);
return `
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:11px;font-weight:800;letter-spacing:.04em;">${escapeHtml(activityLabel)}</span>
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:11px;font-weight:700;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
</span>
`;
}
/**
* 猜谜活动开题消息统一渲染为卡片。
*/
function buildQuizStartHtml(msg, timeStr) {
const quizMeta = normalizeQuizRoundPayload(msg);
const rawHint = String(quizMeta.hint || msg.content || "")
.replace(/^🧩\s*/, "")
.replace(/^📣\s*/, "")
.replace(/^【[^】]+】\s*第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
.replace(/^【[^】]+】\s*/u, "")
.replace(/^第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
.replace(/^题面:\s*/u, "")
.trim();
const safeHint = escapeHtml(rawHint);
return `
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#faf5ff);border:1px solid rgba(124,58,237,.16);box-shadow:0 6px 16px rgba(124,58,237,.08);overflow:hidden;">
<div style="width:30px;height:30px;border-radius:10px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:17px;box-shadow:0 5px 12px rgba(124,58,237,.18);flex-shrink:0;">🧩</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#312e81;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg)}</div>
<div data-quiz-inline-text style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:13px;line-height:1.35;font-weight:700;min-width:220px;flex:1;">
<span>${safeHint}</span>
<span class="msg-time" style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
<span data-quiz-inline-action-anchor></span>
</div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;color:#6d28d9;font-size:11px;flex-shrink:0;margin-left:auto;">
<span style="padding:1px 7px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 7px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
</div>
</div>
`;
}
/**
* 猜谜活动结算消息统一渲染为结果卡片。
*/
function buildQuizResultHtml(msg, timeStr) {
const quizMeta = normalizeQuizRoundPayload(msg);
const winnerHtml = clickableUser(String(msg.winner_username || ""), "#15803d");
const answerText = escapeHtml(quizMeta.answer || String(msg.content || ""));
return `
<div style="display:flex;align-items:flex-start;gap:12px;padding:10px 12px;border-radius:14px;background:linear-gradient(135deg,#f0fdf4,#ecfccb);border:1px solid rgba(22,163,74,.18);box-shadow:0 10px 24px rgba(34,197,94,.10);">
<div style="width:42px;height:42px;border-radius:12px;background:linear-gradient(135deg,#16a34a,#4ade80);display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;box-shadow:0 8px 18px rgba(22,163,74,.22);flex-shrink:0;">🎉</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#16a34a")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:8px;font-size:15px;line-height:1.75;color:#166534;font-weight:700;">【${winnerHtml}】率先答对「${answerText}」</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#166534;font-size:12px;">
<span style="padding:2px 8px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(22,163,74,.12);">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:2px 8px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(22,163,74,.12);">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
</div>
</div>
`;
}
/**
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
*/
@@ -121,14 +201,10 @@ export function appendMessage(msg, renderBatch = null) {
state.trackMaxMsgId(msg.id || 0);
const idiomRoundId = Number.parseInt(
String(msg.idiom_game_round_id || msg.idom_game_round_id || "0"),
10,
);
const isIdiomStartMessage = idiomRoundId > 0
&& msg.from_user === "星海小博士"
&& !msg.action
&& String(msg.content || "").includes("猜成语时间");
const quizMeta = normalizeQuizRoundPayload(msg);
const idiomRoundId = quizMeta.roundId;
const isIdiomStartMessage = isQuizStartMessage(msg)
&& ["星海小博士", "系统传音"].includes(String(msg.from_user || ""));
if (isIdiomStartMessage) {
const existingIdiomNode = document.querySelector(`[data-idiom-round-id="${idiomRoundId}"]`);
@@ -150,6 +226,7 @@ export function appendMessage(msg, renderBatch = null) {
}
if (idiomRoundId > 0) {
div.dataset.idiomRoundId = String(idiomRoundId);
div.dataset.quizRoundId = String(idiomRoundId);
}
if (blockRuleKey) {
div.dataset.blockKey = blockRuleKey;
@@ -210,12 +287,13 @@ export function appendMessage(msg, renderBatch = null) {
html = `${iconImg} ${parsedContent}`;
} else if (msg.action === "idiom_result") {
div.dataset.idiomResult = "1";
const winnerUsername = String(msg.winner_username || "");
const winnerHtml = clickableUser(winnerUsername, "#16a34a");
const answerText = escapeHtml(String(msg.idiom_answer || ""));
const rewardGold = Number.parseInt(String(msg.idiom_result_reward_gold ?? msg.reward_gold ?? 0), 10);
const rewardExp = Number.parseInt(String(msg.idiom_result_reward_exp ?? msg.reward_exp ?? 0), 10);
html = `<span style="color:#16a34a;font-weight:bold;">🎉 【${winnerHtml}】率先答对成语「${answerText}」,获得 ${rewardGold} 金币、${rewardExp} 经验!</span>`;
div.dataset.quizRoundEndedId = String(quizMeta.endedRoundId || quizMeta.roundId || 0);
div.dataset.quizWinnerUsername = String(msg.winner_username || "");
const parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor};">${parsedContent}</span>`;
} else if (isIdiomStartMessage) {
html = buildQuizStartHtml(msg, timeStr);
timeStrOverride = true;
} else if (msg.action === "vip_presence") {
const accent = msg.presence_color || "#f59e0b";
div.style.cssText =
@@ -278,6 +356,7 @@ export function appendMessage(msg, renderBatch = null) {
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
const isQuizStartNotification = isIdiomStartMessage || content.includes("猜谜活动") || content.includes("猜成语时间");
const isPlainNotification =
content.includes("【百家乐】") ||
content.includes("【赛马】") ||
@@ -287,7 +366,23 @@ export function appendMessage(msg, renderBatch = null) {
content.includes("【老虎机】") ||
content.includes("购买了");
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
if (isQuizStartNotification) {
div.style.cssText =
"background:linear-gradient(135deg,#fff7ed,#fffbeb);border:1px solid rgba(245,158,11,.28);border-left:4px solid #f59e0b;border-radius:12px;padding:8px 12px;margin:4px 0;box-shadow:0 10px 24px rgba(245,158,11,.14);";
html = `
<div style="display:flex;align-items:flex-start;gap:10px;">
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#d97706")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:7px;color:#9a3412;font-size:15px;font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
</div>
</div>
`;
timeStrOverride = true;
} else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (isPlainNotification) {
@@ -346,9 +441,9 @@ export function appendMessage(msg, renderBatch = null) {
div.innerHTML = html;
attachIdiomAnswerButton(div, msg);
// 历史消息恢复或实时结算时,都立即移除对应回合的旧答题按钮。
if (Number.parseInt(String(msg.idiom_game_round_ended_id || "0"), 10) > 0) {
removeIdiomAnswerButtons(Number.parseInt(String(msg.idiom_game_round_ended_id), 10));
// 历史消息恢复或实时结算时,都立即对应回合按钮置为结束态,保留消息结构便于回看
if (quizMeta.endedRoundId > 0) {
disableIdiomAnswerButtons(quizMeta.endedRoundId, "本回合已结束", String(msg.winner_username || ""));
}
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
+12 -3
View File
@@ -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 "猜成语";
}
+648
View File
@@ -0,0 +1,648 @@
// 猜谜活动前端模块
// 监听 RiddleGameStarted / RiddleGameAnswered 事件,提供答题弹窗与刷新恢复能力。
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
let currentRoundId = 0;
let currentRoomId = 0;
let currentQuizType = "idiom";
const QUIZ_TYPES = ["idiom", "brain_teaser"];
/**
* 兼容新旧字段,提取前端统一使用的猜谜活动回合信息。
*/
export function normalizeQuizRoundPayload(payload) {
const source = payload && typeof payload === "object" ? payload : {};
const quizType = String(source.quiz_type || source.idiom_type || "idiom");
const quizTypeLabel = String(source.quiz_type_label || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
const roundId = Number.parseInt(
String(source.quiz_round_id || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
10,
);
const endedRoundId = Number.parseInt(
String(source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
10,
);
const rewardGold = Number.parseInt(
String(source.quiz_reward_gold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? 0),
10,
);
const rewardExp = Number.parseInt(
String(source.quiz_reward_exp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? 0),
10,
);
const hint = String(source.quiz_hint || source.hint || source.content || "");
const answer = String(source.quiz_answer || source.idiom_answer || source.answer || "");
return {
quizType,
quizTypeLabel,
roundId: Number.isNaN(roundId) ? 0 : roundId,
endedRoundId: Number.isNaN(endedRoundId) ? 0 : endedRoundId,
rewardGold: Number.isNaN(rewardGold) ? 0 : rewardGold,
rewardExp: Number.isNaN(rewardExp) ? 0 : rewardExp,
hint,
answer,
};
}
/**
* 统一构建“猜谜活动 + 题型”展示标题。
*/
export function buildQuizActivityTitle(payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
return {
activityLabel: "猜谜活动",
typeLabel: quizMeta.quizTypeLabel || "谜题",
quizType: quizMeta.quizType,
};
}
/**
* 判断一条消息是否属于开题消息。
*/
export function isQuizStartMessage(payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
const action = String(payload?.action || "");
return quizMeta.roundId > 0 && quizMeta.endedRoundId <= 0 && !action;
}
/**
* 查找当前回合是否已经有对应的聊天室消息节点。
*/
function findIdiomRoundMessageNode(roundId) {
if (roundId <= 0) {
return null;
}
return document.querySelector(`[data-idiom-round-id="${roundId}"]`);
}
/**
* 刷新后若历史消息里缺少当前进行中的开题卡片,则主动补回一条系统传音消息。
*/
function restoreCurrentQuizMessage(roomId, payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
if (quizMeta.roundId <= 0 || !quizMeta.hint) {
return;
}
if (findIdiomRoundMessageNode(quizMeta.roundId)) {
return;
}
const { activityLabel, typeLabel } = buildQuizActivityTitle(payload);
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-start-restore-${quizMeta.roundId}`,
room_id: roomId,
from_user: "系统传音",
to_user: "大家",
content: `🧩 【${activityLabel}${typeLabel}${quizMeta.hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: quizMeta.roundId,
quiz_hint: quizMeta.hint,
quiz_reward_gold: quizMeta.rewardGold,
quiz_reward_exp: quizMeta.rewardExp,
idiom_game_round_id: quizMeta.roundId,
idiom_reward_gold: quizMeta.rewardGold,
idiom_reward_exp: quizMeta.rewardExp,
sent_at: timeStr,
});
}
/**
* 为指定回合创建统一样式的答题按钮。
*/
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp, typeLabel, quizType = "idiom") {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.idiomAnswerBtn = String(roundId);
btn.dataset.quizAnswerBtn = String(roundId);
btn.dataset.idiomHint = hint;
btn.dataset.quizHint = hint;
btn.dataset.idiomGold = String(rewardGold);
btn.dataset.quizGold = String(rewardGold);
btn.dataset.idiomExp = String(rewardExp);
btn.dataset.quizExp = String(rewardExp);
btn.dataset.quizTypeLabel = typeLabel;
btn.dataset.quizType = quizType;
btn.dataset.quizEnded = "0";
btn.textContent = "🎯 立即答题";
btn.style.cssText =
"padding:4px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
"font-weight:700;line-height:1.2;vertical-align:middle;box-shadow:0 4px 10px rgba(124,58,237,.18);";
return btn;
}
/**
* 查找指定回合的所有答题按钮。
*/
function queryQuizAnswerButtons(roundId = 0) {
const selector = roundId > 0
? `[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`
: "[data-quiz-answer-btn], [data-idiom-answer-btn]";
return Array.from(document.querySelectorAll(selector));
}
/**
* 读取当前页面上该回合已渲染的结算消息,用于历史恢复时补挂答对人名字。
*/
function findQuizWinnerUsername(roundId = 0) {
if (roundId <= 0) {
return "";
}
const resultNode = document.querySelector(`[data-quiz-round-ended-id="${roundId}"]`);
return String(resultNode?.dataset?.quizWinnerUsername || "");
}
/**
* 清理指定回合的所有答题按钮。
*/
export function removeIdiomAnswerButtons(roundId = 0) {
queryQuizAnswerButtons(roundId).forEach((button) => button.remove());
}
/**
* 为结束态按钮补一个答对人标记,避免用户只看到“已结束”不知道是谁抢到了。
*/
function syncQuizWinnerLabel(button, winnerUsername = "") {
if (!(button instanceof HTMLElement)) {
return;
}
const existingLabel = button.parentElement?.querySelector(`[data-quiz-winner-label="${button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || ""}"]`);
if (!winnerUsername) {
existingLabel?.remove();
return;
}
const winnerLabel = existingLabel || document.createElement("span");
winnerLabel.dataset.quizWinnerLabel = String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0");
winnerLabel.textContent = `答对:${winnerUsername}`;
winnerLabel.style.cssText = "margin-left:6px;font-size:11px;line-height:1.2;color:#64748b;font-weight:700;white-space:nowrap;";
if (!existingLabel) {
button.insertAdjacentElement("afterend", winnerLabel);
}
}
/**
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
*/
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
queryQuizAnswerButtons(roundId).forEach((button) => {
button.disabled = true;
button.dataset.quizEnded = "1";
button.style.background = "linear-gradient(135deg,#94a3b8,#cbd5e1)";
button.style.color = "#f8fafc";
button.style.cursor = "not-allowed";
button.style.boxShadow = "none";
button.style.opacity = ".92";
button.style.padding = "4px 12px";
button.style.fontSize = "11px";
button.style.lineHeight = "1.2";
button.title = endedText;
button.textContent = "已结束";
syncQuizWinnerLabel(button, winnerUsername);
});
}
/**
* 根据当前回合状态同步按钮可点击性,避免刷新后仍显示过期入口。
*/
function syncQuizAnswerButtons(activeRoundIds) {
const activeIds = new Set((Array.isArray(activeRoundIds) ? activeRoundIds : [activeRoundIds]).filter((roundId) => roundId > 0));
queryQuizAnswerButtons().forEach((button) => {
const buttonRoundId = Number.parseInt(String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0"), 10);
if (activeIds.has(buttonRoundId)) {
button.disabled = false;
button.dataset.quizEnded = "0";
button.style.background = "linear-gradient(135deg,#7c3aed,#a78bfa)";
button.style.color = "#fff";
button.style.cursor = "pointer";
button.style.boxShadow = "0 4px 10px rgba(124,58,237,.18)";
button.style.opacity = "1";
button.style.padding = "4px 12px";
button.style.fontSize = "11px";
button.style.lineHeight = "1.2";
button.title = "";
button.textContent = "🎯 立即答题";
syncQuizWinnerLabel(button, "");
return;
}
disableIdiomAnswerButtons(buttonRoundId);
});
}
/**
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
*/
export function attachIdiomAnswerButton(messageNode, message) {
if (!messageNode || !message) {
return;
}
const quizMeta = normalizeQuizRoundPayload(message);
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (roundId <= 0) {
return;
}
if (quizMeta.endedRoundId > 0) {
return;
}
if (!["星海小博士", "系统传音"].includes(String(message.from_user || ""))) {
return;
}
if (messageNode.querySelector(`[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`)) {
return;
}
const button = buildIdiomAnswerButton(roundId, quizMeta.hint, quizMeta.rewardGold, quizMeta.rewardExp, quizMeta.quizTypeLabel, quizMeta.quizType);
const inlineActionAnchor = messageNode.querySelector("[data-quiz-inline-action-anchor]");
if (inlineActionAnchor?.parentNode) {
inlineActionAnchor.parentNode.insertBefore(button, inlineActionAnchor.nextSibling);
} else {
messageNode.appendChild(button);
}
if (quizMeta.endedRoundId > 0) {
disableIdiomAnswerButtons(roundId);
return;
}
const winnerUsername = findQuizWinnerUsername(roundId);
if (winnerUsername) {
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
}
}
/**
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
*/
async function syncCurrentIdiomRound() {
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
if (roomId <= 0) {
return;
}
currentRoomId = roomId;
try {
const responses = await Promise.all(QUIZ_TYPES.map(async (quizType) => {
const response = await fetch(`/riddle-quiz/current?room_id=${roomId}&type=${encodeURIComponent(quizType)}`, {
headers: {
Accept: "application/json",
},
});
return response.json();
}));
responses.forEach((data) => {
if (data?.status === "success" && data?.data) {
restoreCurrentQuizMessage(roomId, data.data);
}
});
const activeRoundIds = responses
.map((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10))
.filter((roundId) => roundId > 0);
currentRoundId = activeRoundIds[0] || 0;
currentQuizType = responses.find((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10) === currentRoundId)?.data?.quiz_type || currentQuizType;
syncQuizAnswerButtons(activeRoundIds);
} catch (_error) {
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
}
}
/**
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
*/
function handleRiddleGameStarted(e) {
const quizMeta = normalizeQuizRoundPayload(e.detail);
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
const { roundId, hint, rewardGold, rewardExp } = quizMeta;
const message = String(e.detail?.message || "");
if (!roundId || !hint) return;
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
currentQuizType = quizMeta.quizType || "idiom";
// 线上如果 MessageSent 补消息没有到达,这里主动补一条公屏消息兜底;
// 本地或正常链路下若消息已存在,则只补挂答题按钮,避免重复渲染。
const existingMessageNode = findIdiomRoundMessageNode(roundId);
if (existingMessageNode) {
attachIdiomAnswerButton(existingMessageNode, {
from_user: "星海小博士",
content: message || `🧩 【${activityLabel}${typeLabel}${hint}`,
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: roundId,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
idiom_game_round_id: roundId,
idiom_reward_gold: rewardGold,
idiom_reward_exp: rewardExp,
});
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
return;
}
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-start-live-${roundId}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "系统传音",
to_user: "大家",
content: message || `🧩 【${activityLabel}${typeLabel}${hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: roundId,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
idiom_game_round_id: roundId,
idiom_reward_gold: rewardGold,
idiom_reward_exp: rewardExp,
sent_at: timeStr,
});
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
}
/**
* 收到猜成语结果事件。
*/
function handleRiddleGameAnswered(e) {
const quizMeta = normalizeQuizRoundPayload(e.detail);
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
const answer = quizMeta.answer;
const winnerUsername = String(e.detail?.winner_username || "");
const rewardGold = quizMeta.rewardGold;
const rewardExp = quizMeta.rewardExp;
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (!answer) return;
currentRoundId = 0;
currentQuizType = "idiom";
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
// 关闭当前用户的答题弹窗(如果开着的话)
const answerModal = document.getElementById("idiom-answer-modal");
if (answerModal && answerModal.style.display !== "none") {
answerModal.style.display = "none";
}
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-result-live-${roundId}-${Date.now()}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "系统传音",
to_user: "大家",
content: `🎉 【${winnerUsername}】率先答对${typeLabel}${answer}」,获得 ${rewardGold} 金币、${rewardExp} 经验!`,
is_secret: false,
font_color: "#16a34a",
action: "idiom_result",
winner_username: winnerUsername,
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_answer: answer,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
quiz_round_ended_id: roundId,
idiom_answer: answer,
idiom_result_reward_gold: rewardGold,
idiom_result_reward_exp: rewardExp,
idiom_game_round_ended_id: roundId,
sent_at: timeStr,
});
// ── Toast 通知(所有用户都能看到) ──
window.chatToast?.show({
title: `🧩 ${activityLabel} · ${typeLabel}`,
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
icon: "🎉",
color: "#16a34a",
duration: 6000,
});
}
/**
* 打开答题弹窗。
*/
function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel = "成语题", quizType = "idiom") {
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
currentQuizType = quizType || "idiom";
const modal = document.getElementById("idiom-answer-modal");
if (!modal) return;
const hintEl = document.getElementById("idiom-answer-hint");
const rewardEl = document.getElementById("idiom-answer-reward");
const typeEl = document.getElementById("idiom-answer-type");
if (hintEl) hintEl.textContent = hint;
if (rewardEl) rewardEl.textContent = `🎁 答对奖励:${rewardGold} 金币 + ${rewardExp} 经验`;
if (typeEl) typeEl.textContent = typeLabel;
modal.style.display = "flex";
const input = document.getElementById("idiom-answer-input");
if (input) {
input.value = "";
input.focus();
input.disabled = false;
}
const submitBtn = document.getElementById("idiom-answer-submit");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
const feedbackEl = document.getElementById("idiom-answer-feedback");
if (feedbackEl) feedbackEl.textContent = "";
}
/**
* 关闭答题弹窗。
*/
function closeIdiomAnswerModal() {
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.style.display = "none";
}
/**
* 提交答案。
*/
async function submitIdiomAnswer() {
const input = document.getElementById("idiom-answer-input");
const feedbackEl = document.getElementById("idiom-answer-feedback");
const submitBtn = document.getElementById("idiom-answer-submit");
if (!input || !feedbackEl || !submitBtn) return;
const answer = input.value.trim();
if (!answer) {
feedbackEl.textContent = "请输入答案";
feedbackEl.style.color = "#ef4444";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中...";
try {
const response = await fetch("/riddle-quiz/answer", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
round_id: currentRoundId,
answer: answer,
room_id: currentRoomId,
quiz_type: currentQuizType,
}),
});
const data = await response.json();
if (data.status === "success") {
feedbackEl.textContent = data.message || "🎉 回答正确!";
feedbackEl.style.color = "#16a34a";
input.disabled = true;
disableIdiomAnswerButtons(
currentRoundId,
"本回合已结束",
String(window.chatContext?.username || ""),
);
// 延迟关闭弹窗
setTimeout(() => {
closeIdiomAnswerModal();
}, 2000);
} else {
feedbackEl.textContent = data.message || "答案不正确";
feedbackEl.style.color = "#ef4444";
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
disableIdiomAnswerButtons(currentRoundId, data.message || "本回合已结束");
}
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
input.focus();
input.select();
}
} catch (error) {
feedbackEl.textContent = "网络错误,请稍后重试";
feedbackEl.style.color = "#ef4444";
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
}
// ── 事件绑定 ──
export function bindIdiomQuizControls() {
// 已经绑定的不再重复绑定
if (document.getElementById("idiom-answer-modal")?.dataset?.idiomBound) return;
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.dataset.idiomBound = "1";
// 关闭按钮
document.addEventListener("click", (e) => {
const closeBtn = e.target.closest("[data-idiom-answer-close]");
if (closeBtn) {
closeIdiomAnswerModal();
return;
}
// 点击遮罩层关闭
const overlay = e.target.closest("#idiom-answer-modal");
if (overlay && e.target === overlay) {
closeIdiomAnswerModal();
}
});
// 提交按钮
document.addEventListener("click", (e) => {
const submitBtn = e.target.closest("[data-idiom-answer-submit]");
if (submitBtn) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 输入框 Enter 提交
document.addEventListener("keydown", (e) => {
const input = e.target.closest("#idiom-answer-input");
if (input && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 聊天消息中的【答题】按钮点击
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-idiom-answer-btn]");
if (!btn) return;
if (btn instanceof HTMLButtonElement && (btn.disabled || btn.dataset.quizEnded === "1")) {
return;
}
const roundId = parseInt(btn.dataset.quizAnswerBtn || btn.dataset.idiomAnswerBtn || "0", 10);
const hint = btn.dataset.quizHint || btn.dataset.idiomHint || "";
const rewardGold = parseInt(btn.dataset.quizGold || btn.dataset.idiomGold || "0", 10);
const rewardExp = parseInt(btn.dataset.quizExp || btn.dataset.idiomExp || "0", 10);
const typeLabel = btn.dataset.quizTypeLabel || "成语题";
currentQuizType = btn.dataset.quizType || (typeLabel === "脑筋急转弯" ? "brain_teaser" : "idiom");
if (roundId > 0) {
openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel, currentQuizType);
}
});
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
window.setTimeout(() => {
syncCurrentIdiomRound();
}, 0);
}
// ── 挂载到 window ──
window.openIdiomAnswerModal = openIdiomAnswerModal;
window.closeIdiomAnswerModal = closeIdiomAnswerModal;
window.submitIdiomAnswer = submitIdiomAnswer;
window.handleRiddleGameStarted = handleRiddleGameStarted;
window.handleRiddleGameAnswered = handleRiddleGameAnswered;
+2 -2
View File
@@ -277,12 +277,12 @@ export function initChat(roomId) {
window.dispatchEvent(new CustomEvent("chat:pat", { detail: e }));
})
// 监听猜成语出题
.listen("IdiomGameStarted", (e) => {
.listen("RiddleGameStarted", (e) => {
console.log("猜成语:", e);
window.dispatchEvent(new CustomEvent("chat:idiom-started", { detail: e }));
})
// 监听猜成语答题结果
.listen("IdiomGameAnswered", (e) => {
.listen("RiddleGameAnswered", (e) => {
console.log("猜成语结果:", e);
window.dispatchEvent(new CustomEvent("chat:idiom-answered", { detail: e }));
})
@@ -3,6 +3,11 @@
@section('title', '游戏管理')
@section('content')
@php
$riddleTypeOptions = \App\Models\Riddle::typeOptions();
$availableRooms = \App\Models\Room::orderBy('id')->get();
@endphp
<div class="space-y-6">
{{-- 页头 --}}
@@ -82,7 +87,8 @@
{{-- 参数配置区域 --}}
<div class="p-5">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST"
@if ($game->game_key === 'idiom') data-idiom-config-form @endif>
@csrf
@php
@@ -95,53 +101,140 @@
$paramKeys = array_values(array_filter($paramKeys, fn ($key) => ! in_array($key, $hiddenLegacyKeys, true)));
@endphp
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@foreach ($paramKeys as $paramKey)
@php
$paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '');
if ($game->game_key === 'mystery_box') {
$legacyFallbackMap = [
'normal_reward_min' => 'min_reward',
'normal_reward_max' => 'max_reward',
'rare_reward_min' => 'rare_min_reward',
'rare_reward_max' => 'rare_max_reward',
];
if (($paramValue === '' || $paramValue === null) && isset($legacyFallbackMap[$paramKey])) {
$paramValue = $params[$legacyFallbackMap[$paramKey]] ?? $paramValue;
}
}
@endphp
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">
{{ $meta['label'] }}
@if ($meta['unit'])
<span class="font-normal text-gray-400">{{ $meta['unit'] }}</span>
@endif
</label>
@if ($meta['type'] === 'boolean')
<select name="params[{{ $paramKey }}]"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
<option value="1" {{ $paramValue ? 'selected' : '' }}></option>
<option value="0" {{ !$paramValue ? 'selected' : '' }}></option>
</select>
@elseif ($meta['type'] === 'array')
<input type="text" name="params[{{ $paramKey }}]"
value="{{ implode(',', (array) $paramValue) }}"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400"
placeholder="多个值用逗号分隔">
@else
<input type="{{ $meta['type'] }}" name="params[{{ $paramKey }}]"
value="{{ $paramValue }}" step="{{ $meta['step'] ?? 1 }}"
min="{{ $meta['min'] ?? 0 }}"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
@endif
@if ($game->game_key === 'idiom')
@php
$sharedConfig = gameRiddleSharedConfig($params);
$checkedRoomIds = collect($sharedConfig['room_ids'])->map(fn ($roomId) => (int) $roomId)->all();
@endphp
<div class="space-y-4">
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 p-4 text-xs leading-6 text-indigo-700">
猜成语与脑筋急转弯共用同一套奖励、过期时间、自动出题间隔与参与房间范围配置。
手动出题时再单独选择题型即可。
</div>
@endforeach
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/70 p-4" data-idiom-config-card>
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-bold text-slate-800">猜谜活动公共设置</div>
<div class="text-xs text-slate-500">以下参数会同时作用于猜成语与脑筋急转弯。</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@foreach ($riddleTypeOptions as $typeLabel)
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-500 shadow-sm">{{ $typeLabel }}</span>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">答对奖励金币</label>
<input type="number" name="params[reward_gold]"
value="{{ old('params.reward_gold', $sharedConfig['reward_gold']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">答对奖励经验</label>
<input type="number" name="params[reward_exp]"
value="{{ old('params.reward_exp', $sharedConfig['reward_exp']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">题目过期时间</label>
<input type="number" name="params[expire_minutes]"
value="{{ old('params.expire_minutes', $sharedConfig['expire_minutes']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示不过期。</p>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">自动出题间隔</label>
<input type="number" name="params[auto_start_interval]"
value="{{ old('params.auto_start_interval', $sharedConfig['auto_start_interval']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示仅手动出题。</p>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">参与房间模式</label>
<select name="params[room_scope_mode]"
data-idiom-room-mode
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="all" @selected($sharedConfig['room_mode'] === 'all')>全部房间</option>
<option value="single" @selected($sharedConfig['room_mode'] === 'single')>单选房间</option>
<option value="multiple" @selected($sharedConfig['room_mode'] === 'multiple')>多选房间</option>
</select>
</div>
<div class="xl:col-span-2">
<label class="mb-2 block text-xs font-bold text-gray-600" data-idiom-room-label>参与房间</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-dashed border-slate-200 bg-white p-3 lg:grid-cols-3">
@foreach ($availableRooms as $room)
<label class="flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm text-slate-600">
<input type="checkbox"
name="params[room_ids][]"
value="{{ $room->id }}"
@checked(in_array((int) $room->id, $checkedRoomIds, true))
data-idiom-room-checkbox
class="rounded border-slate-300 text-indigo-600">
<span>#{{ $room->id }} {{ $room->name }}</span>
</label>
@endforeach
</div>
<p class="mt-2 text-xs text-gray-400">单选模式下只保留一个房间,多选模式可同时勾选多个房间。</p>
</div>
</div>
</div>
</div>
@else
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
@foreach ($paramKeys as $paramKey)
@php
$paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '');
if ($game->game_key === 'mystery_box') {
$legacyFallbackMap = [
'normal_reward_min' => 'min_reward',
'normal_reward_max' => 'max_reward',
'rare_reward_min' => 'rare_min_reward',
'rare_reward_max' => 'rare_max_reward',
];
if (($paramValue === '' || $paramValue === null) && isset($legacyFallbackMap[$paramKey])) {
$paramValue = $params[$legacyFallbackMap[$paramKey]] ?? $paramValue;
}
}
@endphp
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">
{{ $meta['label'] }}
@if ($meta['unit'])
<span class="font-normal text-gray-400">{{ $meta['unit'] }}</span>
@endif
</label>
@if ($meta['type'] === 'boolean')
<select name="params[{{ $paramKey }}]"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="1" {{ $paramValue ? 'selected' : '' }}></option>
<option value="0" {{ !$paramValue ? 'selected' : '' }}></option>
</select>
@elseif ($meta['type'] === 'array')
<input type="text" name="params[{{ $paramKey }}]"
value="{{ implode(',', (array) $paramValue) }}"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400"
placeholder="多个值用逗号分隔">
@else
<input type="{{ $meta['type'] }}" name="params[{{ $paramKey }}]"
value="{{ $paramValue }}" step="{{ $meta['step'] ?? 1 }}"
min="{{ $meta['min'] ?? 0 }}"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
@endif
</div>
@endforeach
</div>
@endif
<div class="mt-4 flex items-center gap-3">
<button type="submit"
@@ -152,6 +245,32 @@
</div>
</form>
@if ($game->game_key === 'idiom')
<div class="mt-4 border-t border-gray-100 pt-4">
<div class="mb-3 text-xs font-bold text-gray-600">🧩 手动出题</div>
<div class="flex flex-wrap items-center gap-3">
<select id="idiom-manual-room"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-slate-700">
@foreach ($availableRooms as $room)
<option value="{{ $room->id }}">#{{ $room->id }} {{ $room->name }}</option>
@endforeach
</select>
<select id="idiom-manual-type"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-slate-700">
@foreach ($riddleTypeOptions as $typeKey => $typeLabel)
<option value="{{ $typeKey }}">{{ $typeLabel }}</option>
@endforeach
</select>
<button type="button" id="idiom-manual-start-btn"
data-idiom-start-url="{{ route('riddle-quiz.start') }}"
class="rounded-lg bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:opacity-90">
立即出题
</button>
<span class="text-xs text-gray-400">先选房间,再选题型,后台会按对应题型配置发题。</span>
</div>
</div>
@endif
{{-- 神秘箱子:手动投放区域 --}}
@if ($game->game_key === 'mystery_box')
<div class="mt-4 pt-4 border-t border-gray-100">
@@ -416,4 +535,136 @@
default => [],
};
}
/**
* 解析猜谜活动公共配置,并兼容旧版题型拆分配置。
*
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
*/
function gameRiddleSharedConfig(array $params): array
{
$fallbackTypeConfig = collect((array) ($params['type_configs'] ?? []))
->first(fn ($typeConfig) => is_array($typeConfig) && $typeConfig !== [], []);
$roomMode = (string) ($params['room_scope_mode'] ?? ($fallbackTypeConfig['room_mode'] ?? 'single'));
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
$roomMode = 'single';
}
return [
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($fallbackTypeConfig['reward_gold'] ?? 50))),
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($fallbackTypeConfig['reward_exp'] ?? 30))),
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($fallbackTypeConfig['expire_minutes'] ?? 5))),
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($fallbackTypeConfig['auto_start_interval'] ?? 0))),
'room_mode' => $roomMode,
'room_ids' => collect((array) ($params['room_ids'] ?? ($fallbackTypeConfig['room_ids'] ?? [])))
->map(fn ($roomId) => (int) $roomId)
->filter(fn ($roomId) => $roomId > 0)
->unique()
->values()
->all(),
];
}
@endphp
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
function showAdminAlert(message, title = '提示', icon = '️') {
if (window.adminDialog?.alert) {
window.adminDialog.alert(message, title, icon);
return;
}
window.alert(message);
}
document.querySelectorAll('[data-idiom-config-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
let hasValidationError = false;
const modeSelect = this.querySelector('[data-idiom-room-mode]');
const roomCheckboxes = Array.from(this.querySelectorAll('[data-idiom-room-checkbox]'));
const checkedRooms = roomCheckboxes.filter(function (checkbox) {
return checkbox.checked;
});
if (modeSelect) {
if (modeSelect.value === 'single' && checkedRooms.length > 1) {
showAdminAlert('猜谜活动处于单选房间模式时,只能勾选一个房间。', '房间选择有误', '⚠️');
hasValidationError = true;
}
if (modeSelect.value === 'single' && checkedRooms.length === 0) {
const firstRoomCheckbox = roomCheckboxes[0];
if (firstRoomCheckbox) {
firstRoomCheckbox.checked = true;
}
}
if (modeSelect.value === 'multiple' && checkedRooms.length === 0) {
showAdminAlert('猜谜活动处于多选房间模式时,请至少选择一个房间。', '房间选择有误', '⚠️');
hasValidationError = true;
}
}
if (hasValidationError) {
event.preventDefault();
}
});
});
const manualStartButton = document.getElementById('idiom-manual-start-btn');
if (!manualStartButton) {
return;
}
manualStartButton.addEventListener('click', function () {
const startUrl = this.getAttribute('data-idiom-start-url') || '';
const roomId = Number.parseInt(document.getElementById('idiom-manual-room')?.value || '0', 10);
const quizType = document.getElementById('idiom-manual-type')?.value || '';
if (!startUrl || roomId <= 0 || !quizType) {
showAdminAlert('请先选择房间和题型。', '手动出题', '⚠️');
return;
}
const button = this;
button.disabled = true;
button.textContent = '出题中...';
fetch(startUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: roomId,
quiz_type: quizType,
}),
})
.then(function (response) {
return response.json();
})
.then(function (response) {
if (response.status === 'success') {
showAdminAlert('题目已发送到目标房间。', '手动出题成功', '✅');
return;
}
showAdminAlert(response.message || '出题失败', '手动出题失败', '❌');
})
.catch(function () {
showAdminAlert('网络错误,出题失败', '手动出题失败', '🌐');
})
.finally(function () {
button.disabled = false;
button.textContent = '立即出题';
});
});
});
</script>
@endpush
@@ -1,338 +0,0 @@
@extends('admin.layouts.app')
@section('title', '猜成语题库管理')
@section('content')
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
@php
$idiomPayload = $idioms->mapWithKeys(
fn($item) => [
(string) $item->id => [
'id' => $item->id,
'answer' => $item->answer,
'hint' => $item->hint,
'sort' => $item->sort,
'is_active' => (bool) $item->is_active,
'update_url' => route('admin.idioms.update', $item->id),
'toggle_url' => route('admin.idioms.toggle', $item->id),
],
],
);
$idiomConfig = \App\Models\GameConfig::forGame('idiom');
$idiomParams = $idiomConfig?->params ?? [];
@endphp
<script type="application/json" id="admin-idioms-data">@json($idiomPayload)</script>
<div class="{{ $adminListPageClass }}">
{{-- 页头 --}}
<div class="{{ $adminListHeaderCardClass }}">
<div>
<h2 class="{{ $adminListHeaderTitleClass }}">🧩 猜成语题库管理</h2>
<p class="{{ $adminListHeaderSubtitleClass }}">
管理猜成语游戏的题目库,共 <strong class="text-indigo-600">{{ $idioms->count() }}</strong> 条题目
</p>
</div>
</div>
{{-- 游戏参数 + 出题 --}}
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<h3 class="{{ $adminListSectionTitleClass }}">⚙️ 游戏参数</h3>
</div>
<form action="{{ route('admin.idioms.settings.save') }}" method="POST" class="p-5">
@csrf
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="{{ $adminListFilterLabelClass }}">答对奖励金币</label>
<input type="number" name="reward_gold"
value="{{ old('reward_gold', $idiomParams['reward_gold'] ?? 50) }}" min="0"
class="w-full {{ $adminListFilterInputClass }}">
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">答对奖励经验</label>
<input type="number" name="reward_exp"
value="{{ old('reward_exp', $idiomParams['reward_exp'] ?? 30) }}" min="0"
class="w-full {{ $adminListFilterInputClass }}">
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">自动出题间隔(分钟)</label>
<input type="number" name="auto_start_interval"
value="{{ old('auto_start_interval', $idiomParams['auto_start_interval'] ?? 0) }}" min="0"
class="w-full {{ $adminListFilterInputClass }}">
<p class="text-xs text-gray-400 mt-1">0=仅手动出题</p>
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">题目过期时间(分钟)</label>
<input type="number" name="expire_minutes"
value="{{ old('expire_minutes', $idiomParams['expire_minutes'] ?? 5) }}" min="0"
class="w-full {{ $adminListFilterInputClass }}">
<p class="text-xs text-gray-400 mt-1">0=不过期;大于 0 时超时会自动公布答案并结束回合</p>
</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
💾 保存参数
</button>
<span class="text-sm text-gray-400">|</span>
<label class="text-sm text-gray-600">选择房间:</label>
<select id="idiom-start-room" class="border border-gray-300 rounded-lg px-3 py-1.5 text-sm">
@foreach (\App\Models\Room::orderBy('id')->get() as $room)
<option value="{{ $room->id }}">{{ $room->name }}</option>
@endforeach
</select>
<button type="button" id="idiom-start-btn"
class="inline-flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm font-bold rounded-lg hover:opacity-90 transition">
🧩 出题
</button>
</div>
</form>
</div>
{{-- 题目列表 --}}
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListTableWrapClass }}">
<table class="{{ $adminListTableClass }}">
<thead class="{{ $adminListTableHeadRowClass }}">
<tr>
<th class="{{ $adminListTableHeadCellClass }}">排序</th>
<th class="{{ $adminListTableHeadCellClass }}">成语答案</th>
<th class="{{ $adminListTableHeadCellClass }} w-2/5">谜语提示</th>
<th class="{{ $adminListTableHeadCellClass }} text-center">状态</th>
<th class="{{ $adminListTableHeadCellClass }} text-right">操作</th>
</tr>
</thead>
<tbody class="{{ $adminListTableBodyClass }}">
@foreach ($idioms as $item)
<tr id="row-{{ $item->id }}" class="{{ $adminListTableRowClass }} {{ $item->is_active ? '' : 'opacity-50' }}">
<td class="px-4 py-3 {{ $adminListSecondaryTextClass }}">{{ $item->sort }}</td>
<td class="px-4 py-3 font-bold {{ $adminListPrimaryTextClass }}">{{ $item->answer }}</td>
<td class="px-4 py-3 {{ $adminListBodyTextClass }} text-sm">{{ $item->hint }}</td>
<td class="px-4 py-3 text-center">
<button type="button" data-idiom-toggle-id="{{ $item->id }}"
id="toggle-{{ $item->id }}"
class="{{ $adminListBadgeBaseClass }} px-2 py-1 transition
{{ $item->is_active ? 'border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border-gray-200 bg-gray-100 text-gray-500 hover:bg-gray-200' }}">
{{ $item->is_active ? '启用' : '禁用' }}
</button>
</td>
<td class="px-4 py-3 text-right">
<button type="button" data-idiom-edit-id="{{ $item->id }}"
class="{{ $adminListActionButtonClass }} bg-indigo-50 text-indigo-700 hover:bg-indigo-100 mr-1">
编辑
</button>
<form action="{{ route('admin.idioms.destroy', $item->id) }}" method="POST"
class="inline" data-idiom-delete-confirm="确定删除题目「{{ $item->answer }}」?">
@csrf @method('DELETE')
<button type="submit"
class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100">
删除
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- 新增题目卡片 --}}
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<h3 class="{{ $adminListSectionTitleClass }}"> 新增成语题目</h3>
</div>
<form action="{{ route('admin.idioms.store') }}" method="POST" class="p-5">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="{{ $adminListFilterLabelClass }}">成语答案</label>
<input type="text" name="answer" value="{{ old('answer') }}" placeholder="画蛇添足" required
class="w-full {{ $adminListFilterInputClass }}">
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">排序</label>
<input type="number" name="sort" value="{{ old('sort', 0) }}" min="0"
class="w-full {{ $adminListFilterInputClass }}">
</div>
<div class="md:col-span-2">
<label class="{{ $adminListFilterLabelClass }}">谜语提示</label>
<input type="text" name="hint" value="{{ old('hint') }}" placeholder="🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语" required
class="w-full {{ $adminListFilterInputClass }}">
</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
💾 添加题目
</button>
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" name="is_active" value="1" checked class="rounded">
立即启用
</label>
</div>
</form>
</div>
</div>
{{-- 编辑弹窗 --}}
<div id="edit-modal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl w-full max-w-lg shadow-2xl">
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-bold text-gray-800">✏️ 编辑成语题目</h3>
<button type="button" data-idiom-edit-close class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<form id="edit-form" method="POST" class="p-5">
@csrf @method('PUT')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">成语答案</label>
<input type="text" name="answer" id="edit-answer" required
class="w-full border border-gray-300 rounded-lg p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">排序</label>
<input type="number" name="sort" id="edit-sort" min="0"
class="w-full border border-gray-300 rounded-lg p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">谜语提示</label>
<input type="text" name="hint" id="edit-hint" required
class="w-full border border-gray-300 rounded-lg p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="is_active" id="edit-is-active" value="1" class="rounded">
启用此题目
</label>
</div>
</div>
<div class="mt-5 flex gap-3">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
💾 保存修改
</button>
<button type="button" data-idiom-edit-close
class="{{ $adminListSecondaryButtonClass }}">
取消
</button>
</div>
</form>
</div>
</div>
@endsection
{{-- 前端编辑/切换交互脚本 --}}
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const idiomsDataEl = document.getElementById('admin-idioms-data');
if (!idiomsDataEl) return;
const idiomsData = JSON.parse(idiomsDataEl.textContent || '{}');
// ── 打开编辑弹窗 ──
document.querySelectorAll('[data-idiom-edit-id]').forEach(btn => {
btn.addEventListener('click', function () {
const id = this.dataset.idiomEditId;
const data = idiomsData[id];
if (!data) return;
document.getElementById('edit-answer').value = data.answer;
document.getElementById('edit-hint').value = data.hint;
document.getElementById('edit-sort').value = data.sort;
document.getElementById('edit-is-active').checked = data.is_active;
document.getElementById('edit-form').action = data.update_url;
document.getElementById('edit-modal').classList.remove('hidden');
});
});
// ── 关闭编辑弹窗 ──
document.querySelectorAll('[data-idiom-edit-close]').forEach(btn => {
btn.addEventListener('click', function () {
document.getElementById('edit-modal').classList.add('hidden');
});
});
// ── 切换启用/禁用(AJAX) ──
document.querySelectorAll('[data-idiom-toggle-id]').forEach(btn => {
btn.addEventListener('click', function () {
const id = this.dataset.idiomToggleId;
const data = idiomsData[id];
if (!data) return;
fetch(data.toggle_url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(res => {
if (res.ok) {
const row = document.getElementById('row-' + id);
if (row) row.style.opacity = res.is_active ? '1' : '0.5';
const btn = document.getElementById('toggle-' + id);
if (btn) {
btn.textContent = res.is_active ? '启用' : '禁用';
btn.className = (res.is_active
? 'border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
: 'border-gray-200 bg-gray-100 text-gray-500 hover:bg-gray-200')
+ ' px-2 py-1 transition rounded-full text-xs font-semibold border';
}
}
})
.catch(() => alert('操作失败'));
});
});
// ── 删除确认 ──
document.querySelectorAll('[data-idiom-delete-confirm]').forEach(form => {
form.addEventListener('submit', function (e) {
if (!confirm(this.dataset.idiomDeleteConfirm)) {
e.preventDefault();
}
});
});
// ── 出题按钮 ──
const startBtn = document.getElementById('idiom-start-btn');
if (startBtn) {
startBtn.addEventListener('click', function () {
const roomSelect = document.getElementById('idiom-start-room');
const roomId = roomSelect?.value;
if (!roomId) return;
const btn = this;
btn.disabled = true;
btn.textContent = '出题中...';
fetch('/idiom-quiz/start', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ room_id: parseInt(roomId, 10) }),
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alert('✅ 出题成功!提示已发送到聊天室。');
} else {
alert(data.message || '出题失败');
}
})
.catch(() => alert('网络错误,出题失败'))
.finally(() => {
btn.disabled = false;
btn.textContent = '🧩 出题';
});
});
}
});
</script>
@endpush
+3 -3
View File
@@ -100,9 +100,9 @@
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.fishing.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🎣 钓鱼事件
</a>
<a href="{{ route('admin.idioms.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.idioms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🧩 成语题库
<a href="{{ route('admin.riddles.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.riddles.*') || request()->routeIs('admin.idioms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🧩 谜活动题库
</a>
<a href="{{ route('admin.departments.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.departments.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
@@ -0,0 +1,304 @@
@extends('admin.layouts.app')
@section('title', '猜谜活动题库管理')
@section('content')
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
@php
$quizTypes = $typeOptions;
$idiomPayload = $idioms->mapWithKeys(
fn ($item) => [
(string) $item->id => [
'id' => $item->id,
'type' => $item->type,
'answer' => $item->answer,
'hint' => $item->hint,
'sort' => $item->sort,
'is_active' => (bool) $item->is_active,
'update_url' => route('admin.riddles.update', $item->id),
],
],
);
@endphp
<script type="application/json" id="admin-idioms-data">@json($idiomPayload)</script>
<div class="{{ $adminListPageClass }}">
<div class="{{ $adminListHeaderCardClass }}">
<div>
<h2 class="{{ $adminListHeaderTitleClass }}">🧩 猜谜活动题库管理</h2>
<p class="{{ $adminListHeaderSubtitleClass }}">
这里只管理题目本身;奖励、自动出题、参与房间和手动开题请前往
<a href="{{ route('admin.game-configs.index') }}" class="font-semibold text-indigo-600 hover:text-indigo-500">游戏管理</a>
页面操作。
</p>
</div>
</div>
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<div>
<h3 class="{{ $adminListSectionTitleClass }}">🔎 题库筛选</h3>
<p class="mt-1 text-xs text-slate-500">支持按题型和关键词快速定位题目。</p>
</div>
</div>
<form action="{{ route('admin.riddles.index') }}" method="GET" class="p-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="{{ $adminListFilterLabelClass }}">题型</label>
<select name="type" class="w-full {{ $adminListFilterInputClass }}">
<option value="">全部题型</option>
@foreach ($quizTypes as $quizType => $quizLabel)
<option value="{{ $quizType }}" @selected($selectedType === $quizType)>{{ $quizLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">关键词</label>
<input type="text" name="keyword" value="{{ $keyword }}" placeholder="匹配答案或题面" class="w-full {{ $adminListFilterInputClass }}">
</div>
<div class="flex items-end gap-3">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">筛选</button>
<a href="{{ route('admin.riddles.index') }}" class="{{ $adminListSecondaryButtonClass }}">重置</a>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
@foreach ($quizTypes as $quizType => $quizLabel)
<span class="rounded-full bg-slate-100 px-3 py-1">
{{ $quizLabel }}{{ $typeStats[$quizType] ?? 0 }}
</span>
@endforeach
</div>
</form>
</div>
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<h3 class="{{ $adminListSectionTitleClass }}">📚 题目列表</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="px-4 py-3 text-left font-semibold">ID</th>
<th class="px-4 py-3 text-left font-semibold">题型</th>
<th class="px-4 py-3 text-left font-semibold">标准答案</th>
<th class="px-4 py-3 text-left font-semibold">题面 / 提示</th>
<th class="px-4 py-3 text-left font-semibold">排序</th>
<th class="px-4 py-3 text-left font-semibold">状态</th>
<th class="px-4 py-3 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
@forelse ($idioms as $item)
<tr class="hover:bg-slate-50/80">
<td class="px-4 py-3 text-slate-500">#{{ $item->id }}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold {{ $item->type === \App\Models\Riddle::TYPE_BRAIN_TEASER ? 'bg-amber-100 text-amber-700' : 'bg-indigo-100 text-indigo-700' }}">
{{ \App\Models\Riddle::labelForType($item->type) }}
</span>
</td>
<td class="px-4 py-3 font-semibold text-slate-800">{{ $item->answer }}</td>
<td class="px-4 py-3 text-slate-600">{{ $item->hint }}</td>
<td class="px-4 py-3 text-slate-500">{{ $item->sort }}</td>
<td class="px-4 py-3">
<button type="button"
data-idiom-toggle-url="{{ route('admin.riddles.toggle', $item->id) }}"
class="rounded-full px-3 py-1 text-xs font-semibold transition {{ $item->is_active ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-slate-200 text-slate-500 hover:bg-slate-300' }}">
{{ $item->is_active ? '已启用' : '已禁用' }}
</button>
</td>
<td class="px-4 py-3 text-right">
<button type="button" data-idiom-edit-id="{{ $item->id }}" class="{{ $adminListActionButtonClass }} bg-indigo-50 text-indigo-700 hover:bg-indigo-100">
编辑
</button>
<form action="{{ route('admin.riddles.destroy', $item->id) }}" method="POST" class="inline" data-idiom-delete-confirm="确定删除题目「{{ $item->answer }}」?">
@csrf
@method('DELETE')
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
<button type="submit" class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100">
删除
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-sm text-slate-400">当前筛选条件下暂无题目。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<h3 class="{{ $adminListSectionTitleClass }}"> 新增题目</h3>
</div>
<form action="{{ route('admin.riddles.store') }}" method="POST" class="p-5">
@csrf
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="{{ $adminListFilterLabelClass }}">题型</label>
<select name="type" class="w-full {{ $adminListFilterInputClass }}">
@foreach ($quizTypes as $quizType => $quizLabel)
<option value="{{ $quizType }}" @selected(old('type', $selectedType ?: \App\Models\Riddle::TYPE_IDIOM) === $quizType)>{{ $quizLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">排序</label>
<input type="number" name="sort" min="0" value="{{ old('sort', 0) }}" class="w-full {{ $adminListFilterInputClass }}">
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">标准答案</label>
<input type="text" name="answer" value="{{ old('answer') }}" required placeholder="请输入标准答案" class="w-full {{ $adminListFilterInputClass }}">
</div>
<div class="md:col-span-2">
<label class="{{ $adminListFilterLabelClass }}">题面 / 提示</label>
<input type="text" name="hint" value="{{ old('hint') }}" required placeholder="请输入题面或提示文案" class="w-full {{ $adminListFilterInputClass }}">
</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">💾 添加题目</button>
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="is_active" value="1" checked class="rounded border-slate-300">
立即启用
</label>
</div>
</form>
</div>
</div>
<div id="edit-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-lg rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-slate-100 p-5">
<h3 class="text-base font-bold text-slate-800">✏️ 编辑题目</h3>
<button type="button" data-idiom-edit-close class="text-xl text-slate-400 hover:text-slate-600"></button>
</div>
<form id="edit-form" method="POST" class="p-5">
@csrf
@method('PUT')
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-bold text-slate-600">题型</label>
<select name="type" id="edit-type" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
@foreach ($quizTypes as $quizType => $quizLabel)
<option value="{{ $quizType }}">{{ $quizLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-slate-600">排序</label>
<input type="number" name="sort" id="edit-sort" min="0" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-bold text-slate-600">标准答案</label>
<input type="text" name="answer" id="edit-answer" required class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-bold text-slate-600">题面 / 提示</label>
<input type="text" name="hint" id="edit-hint" required class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="is_active" id="edit-is-active" value="1" class="rounded border-slate-300">
启用此题目
</label>
</div>
</div>
<div class="mt-5 flex gap-3">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">💾 保存修改</button>
<button type="button" data-idiom-edit-close class="{{ $adminListSecondaryButtonClass }}">取消</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const idiomsDataEl = document.getElementById('admin-idioms-data');
const editModal = document.getElementById('edit-modal');
const editForm = document.getElementById('edit-form');
if (!idiomsDataEl || !editModal || !editForm) {
return;
}
const idiomsData = JSON.parse(idiomsDataEl.textContent || '{}');
document.querySelectorAll('[data-idiom-toggle-url]').forEach((button) => {
button.addEventListener('click', async function () {
const url = this.getAttribute('data-idiom-toggle-url');
if (!url) {
return;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
});
const result = await response.json();
if (!result.ok) {
alert(result.message || '状态切换失败。');
return;
}
window.location.reload();
});
});
document.querySelectorAll('[data-idiom-delete-confirm]').forEach((form) => {
form.addEventListener('submit', function (event) {
const message = this.getAttribute('data-idiom-delete-confirm') || '确定删除这条题目吗?';
if (!window.confirm(message)) {
event.preventDefault();
}
});
});
document.querySelectorAll('[data-idiom-edit-id]').forEach((button) => {
button.addEventListener('click', function () {
const idiomId = this.getAttribute('data-idiom-edit-id') || '';
const payload = idiomsData[idiomId];
if (!payload) {
return;
}
document.getElementById('edit-type').value = payload.type;
document.getElementById('edit-answer').value = payload.answer;
document.getElementById('edit-hint').value = payload.hint;
document.getElementById('edit-sort').value = payload.sort;
document.getElementById('edit-is-active').checked = Boolean(payload.is_active);
editForm.action = payload.update_url;
editModal.classList.remove('hidden');
editModal.classList.add('flex');
});
});
document.querySelectorAll('[data-idiom-edit-close]').forEach((button) => {
button.addEventListener('click', function () {
editModal.classList.add('hidden');
editModal.classList.remove('flex');
});
});
});
</script>
@endpush
+6 -2
View File
@@ -257,12 +257,16 @@
style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:99999;justify-content:center;align-items:center;backdrop-filter:blur(3px);">
<div style="background:#fff;border-radius:16px;width:min(90vw,460px);box-shadow:0 24px 64px rgba(0,0,0,.22);overflow:hidden;animation:gdSlideIn .2s ease;">
<div style="padding:18px 22px;background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;">
<div style="font-size:18px;font-weight:bold;">🧩 猜成语</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div style="font-size:18px;font-weight:bold;">🧩 猜谜活动</div>
<span id="idiom-answer-type"
style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:rgba(255,255,255,.16);font-size:11px;font-weight:700;">成语题</span>
</div>
<div id="idiom-answer-reward" style="font-size:12px;margin-top:4px;opacity:.9;"></div>
</div>
<div style="padding:20px 22px;">
<p id="idiom-answer-hint" style="font-size:15px;color:#1e293b;line-height:1.7;margin-bottom:16px;"></p>
<input id="idiom-answer-input" type="text" autocomplete="off" placeholder="输入成语答案..."
<input id="idiom-answer-input" type="text" autocomplete="off" placeholder="输入答案..."
style="width:100%;box-sizing:border-box;padding:12px 14px;border:2px solid #e5e7eb;border-radius:10px;font-size:15px;outline:none;transition:border-color .2s;"
onfocus="this.style.borderColor='#7c3aed'" onblur="this.style.borderColor='#e5e7eb'">
<p id="idiom-answer-feedback" style="margin-top:8px;font-size:13px;min-height:20px;"></p>
@@ -138,7 +138,7 @@ $welcomeMessages = [
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
<input type="checkbox" id="block-sender-idiom" data-chat-block-sender="猜成语">
成语
谜活动
</label>
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">