4ff62e29bd
- 创建 idioms 表(102条谜语式成语题库)和 idiom_game_rounds 表 - 后台成语管理页面:增删改题目 + 游戏参数(金币/经验/间隔)内联设置 + 出题按钮 - IdiomQuizController:出题/答题/当前回合查询,Redis 防并发抢答 - IdiomGameStarted / IdiomGameAnswered 广播事件 - 前端答题弹窗模块:聊天消息带【答题】按钮,点击弹出输入框 - GameConfig 注册 idiom 游戏,由 admin.game-configs 统一管理开关
532 lines
21 KiB
JavaScript
532 lines
21 KiB
JavaScript
// 聊天室 WebSocket 事件监听,从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||
// 所有事件委托通过 window.addEventListener 注册,依赖 window.chatState 共享状态。
|
||
|
||
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
||
import { normalizeDailyStatus } from "./preferences-status.js";
|
||
import { enqueueChatMessage } from "./message-renderer.js";
|
||
|
||
// ── 事件注册标记 ──
|
||
let chatEventsBound = false;
|
||
|
||
// ── 辅助函数 ──
|
||
function csrf() {
|
||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||
}
|
||
|
||
function getState() {
|
||
return window.chatState;
|
||
}
|
||
|
||
/**
|
||
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
|
||
*/
|
||
function initChatWebSocket() {
|
||
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
|
||
window.initChat(window.chatContext.roomId);
|
||
}
|
||
}
|
||
|
||
// ── 禁言逻辑 ──
|
||
function handleMutedEvent(e) {
|
||
const state = getState();
|
||
const d = e.detail;
|
||
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 isMe = d.username === window.chatContext?.username;
|
||
|
||
const div = document.createElement("div");
|
||
div.className = "msg-line";
|
||
div.innerHTML = `<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
|
||
|
||
const targetContainer = isMe
|
||
? document.getElementById("say2")
|
||
: (state?.container);
|
||
if (targetContainer) {
|
||
targetContainer.appendChild(div);
|
||
targetContainer.scrollTop = targetContainer.scrollHeight;
|
||
}
|
||
|
||
if (isMe && d.mute_time > 0) {
|
||
state.isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
|
||
const contentInput = document.getElementById("content");
|
||
const operatorName = d.operator || "管理员";
|
||
if (contentInput) {
|
||
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
|
||
contentInput.disabled = true;
|
||
setTimeout(() => {
|
||
state.isMutedUntil = 0;
|
||
contentInput.placeholder = "在这里输入聊天内容,按 Enter 发送...";
|
||
contentInput.disabled = false;
|
||
const unmuteDiv = document.createElement("div");
|
||
unmuteDiv.className = "msg-line";
|
||
unmuteDiv.innerHTML = '<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
|
||
const say2 = document.getElementById("say2");
|
||
if (say2) {
|
||
say2.appendChild(unmuteDiv);
|
||
say2.scrollTop = say2.scrollHeight;
|
||
}
|
||
}, d.mute_time * 60 * 1000);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Echo 级监听器 ──
|
||
|
||
/**
|
||
* 注册全员清屏监听(ScreenCleared)。
|
||
*/
|
||
function setupScreenClearedListener() {
|
||
if (!window.Echo || !window.chatContext) {
|
||
setTimeout(setupScreenClearedListener, 500);
|
||
return;
|
||
}
|
||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||
.listen("ScreenCleared", (e) => {
|
||
const operator = e.operator;
|
||
const safeOperator = escapeHtml(String(operator || ""));
|
||
|
||
const say1 = document.getElementById("chat-messages-container");
|
||
if (say1) say1.innerHTML = "";
|
||
|
||
const say2 = document.getElementById("chat-messages-container2");
|
||
if (say2) {
|
||
const items = say2.querySelectorAll(".msg-line");
|
||
items.forEach((item) => {
|
||
if (!item.querySelector(".msg-secret")) {
|
||
item.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
const state = getState();
|
||
if (state) {
|
||
state.lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null;
|
||
}
|
||
|
||
const sysDiv = document.createElement("div");
|
||
sysDiv.className = "msg-line";
|
||
const now = new Date();
|
||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||
now.getSeconds().toString().padStart(2, "0");
|
||
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
||
if (say1) {
|
||
say1.appendChild(sysDiv);
|
||
say1.scrollTop = say1.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 注册房间级"刷新全员"监听(BrowserRefreshRequested)。
|
||
*/
|
||
function setupRoomBrowserRefreshListener() {
|
||
if (!window.Echo || !window.chatContext) {
|
||
setTimeout(setupRoomBrowserRefreshListener, 500);
|
||
return;
|
||
}
|
||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||
.listen("BrowserRefreshRequested", (e) => {
|
||
window.dispatchEvent(
|
||
new CustomEvent("chat:browser-refresh-requested", { detail: e })
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 注册开发日志发布通知监听(仅 Room 1)。
|
||
*/
|
||
function setupChangelogPublishedListener() {
|
||
if (!window.Echo || !window.chatContext) {
|
||
setTimeout(setupChangelogPublishedListener, 500);
|
||
return;
|
||
}
|
||
if (window.chatContext.roomId !== 1) return;
|
||
|
||
window.Echo.join("room.1")
|
||
.listen(".ChangelogPublished", (e) => {
|
||
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 safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ""));
|
||
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ""));
|
||
const changelogRoute = window.chatContext?.changelogUrl || "/changelog";
|
||
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, changelogRoute));
|
||
|
||
const sysDiv = document.createElement("div");
|
||
sysDiv.className = "msg-line";
|
||
sysDiv.style.cssText =
|
||
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;";
|
||
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
|
||
📋 【版本更新】v${safeVersion} · ${safeTitle}
|
||
<a href="${safeUrl}" target="_blank" rel="noopener"
|
||
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
|
||
查看详情 →
|
||
</a>
|
||
</span><span class="msg-time">(${timeStr})</span>`;
|
||
|
||
const say1 = document.getElementById("chat-messages-container");
|
||
if (say1) {
|
||
say1.appendChild(sysDiv);
|
||
say1.scrollTop = say1.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 注册五子棋 PvP 邀请通知监听。
|
||
*/
|
||
function setupGomokuInviteListener() {
|
||
if (!window.Echo || !window.chatContext) {
|
||
setTimeout(setupGomokuInviteListener, 500);
|
||
return;
|
||
}
|
||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||
.listen(".gomoku.invite", (e) => {
|
||
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 isSelf = (e.inviter_name === window.chatContext.username);
|
||
const div = document.createElement("div");
|
||
div.className = "msg-line";
|
||
div.style.cssText =
|
||
"background:linear-gradient(135deg,#e8eef8,#f0f4fc); border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;";
|
||
const safeInviterName = escapeHtml(e.inviter_name);
|
||
const gomokuGameId = Number.parseInt(e.game_id, 10) || 0;
|
||
|
||
const acceptBtn = isSelf
|
||
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
|
||
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
|
||
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
|
||
cursor:pointer; font-family:inherit; transition:all .15s;">
|
||
⤴️ 打开面板
|
||
</button>`
|
||
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
|
||
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
|
||
border-radius:12px; background:#336699; color:#fff; font-size:12px;
|
||
cursor:pointer; font-family:inherit; transition:opacity .15s;">
|
||
⚔️ 接受挑战
|
||
</button>`;
|
||
|
||
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
|
||
♟️ 【五子棋】<b>${safeInviterName}</b> 发起了随机对战!${isSelf ? "(等待中)" : ""}
|
||
</span>${acceptBtn}
|
||
<span class="msg-time">(${timeStr})</span>`;
|
||
|
||
const say1 = document.getElementById("chat-messages-container");
|
||
if (say1) {
|
||
say1.appendChild(div);
|
||
say1.scrollTop = say1.scrollHeight;
|
||
}
|
||
|
||
if (!isSelf) {
|
||
setTimeout(() => {
|
||
const btn = document.getElementById(`gomoku-accept-${e.game_id}`);
|
||
if (btn) {
|
||
btn.textContent = "已超时";
|
||
btn.disabled = true;
|
||
btn.style.opacity = ".5";
|
||
btn.style.cursor = "not-allowed";
|
||
}
|
||
}, 60000);
|
||
}
|
||
})
|
||
.listen(".gomoku.finished", (e) => {
|
||
if (e.mode !== "pvp") return;
|
||
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.style.cssText =
|
||
"background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;";
|
||
|
||
const reason = { win: "获胜", draw: "平局", resign: "认输", timeout: "超时" }[e.reason] || "结束";
|
||
let text = "";
|
||
if (e.winner === 0) {
|
||
text = `♟️ 五子棋对局以<b>平局</b>结束!`;
|
||
} else {
|
||
text = `♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>(${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
|
||
}
|
||
|
||
div.innerHTML = `<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
|
||
const say1 = document.getElementById("chat-messages-container");
|
||
if (say1) {
|
||
say1.appendChild(div);
|
||
say1.scrollTop = say1.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── 主事件绑定 ──
|
||
|
||
/**
|
||
* 绑定所有聊天室 WebSocket 事件监听,仅执行一次。
|
||
*/
|
||
export function bindChatEvents() {
|
||
if (chatEventsBound || typeof document === "undefined") {
|
||
return;
|
||
}
|
||
chatEventsBound = true;
|
||
|
||
// WebSocket 初始化
|
||
document.addEventListener("DOMContentLoaded", initChatWebSocket);
|
||
|
||
// chat:here — Presence 初始用户列表
|
||
window.addEventListener("chat:here", (e) => {
|
||
const state = getState();
|
||
if (!state) return;
|
||
|
||
const users = e.detail;
|
||
state.onlineUsers = {};
|
||
users.forEach((u) => {
|
||
window.hydrateOnlineUserPayload(u.username, u);
|
||
});
|
||
|
||
// 注入 AI 小班长
|
||
if (window.chatContext?.chatBotEnabled && window.chatContext.botUser) {
|
||
window.hydrateOnlineUserPayload("AI小班长", window.chatContext.botUser);
|
||
}
|
||
|
||
// 同步当前用户状态
|
||
if (typeof window.setOnlineUserDailyStatus === "function" && typeof window.getCurrentUserDailyStatus === "function") {
|
||
window.setOnlineUserDailyStatus(window.chatContext?.username, window.getCurrentUserDailyStatus());
|
||
}
|
||
if (typeof window.syncDailyStatusUi === "function") {
|
||
window.syncDailyStatusUi();
|
||
}
|
||
window.scheduleRenderUserList(0);
|
||
});
|
||
|
||
// chat:bot-toggled — AI 小班长动态开关
|
||
window.addEventListener("chat:bot-toggled", (e) => {
|
||
const detail = e.detail;
|
||
if (window.chatContext) {
|
||
window.chatContext.chatBotEnabled = detail.isOnline;
|
||
}
|
||
|
||
if (detail.isOnline && detail.user && detail.user.username) {
|
||
window.hydrateOnlineUserPayload(detail.user.username, detail.user);
|
||
if (window.chatContext) window.chatContext.botUser = detail.user;
|
||
} else {
|
||
const state = getState();
|
||
if (state) delete state.onlineUsers["AI小班长"];
|
||
if (window.chatContext) window.chatContext.botUser = null;
|
||
}
|
||
window.scheduleRenderUserList?.();
|
||
});
|
||
|
||
// chat:user-status-updated — 用户每日状态更新
|
||
window.addEventListener("chat:user-status-updated", (e) => {
|
||
const username = e.detail?.username;
|
||
const payload = e.detail?.user;
|
||
if (!username || !payload) return;
|
||
|
||
window.hydrateOnlineUserPayload(username, payload);
|
||
|
||
if (username === window.chatContext?.username) {
|
||
if (window.chatContext) {
|
||
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
|
||
}
|
||
if (typeof window.syncDailyStatusUi === "function") {
|
||
window.syncDailyStatusUi();
|
||
}
|
||
}
|
||
window.scheduleRenderUserList?.();
|
||
});
|
||
|
||
// chat:joining — 用户进入
|
||
window.addEventListener("chat:joining", (e) => {
|
||
const user = e.detail;
|
||
window.hydrateOnlineUserPayload(user.username, user);
|
||
window.scheduleRenderUserList?.();
|
||
});
|
||
|
||
// chat:leaving — 用户离开
|
||
window.addEventListener("chat:leaving", (e) => {
|
||
const user = e.detail;
|
||
const state = getState();
|
||
if (state) delete state.onlineUsers[user.username];
|
||
window.scheduleRenderUserList?.();
|
||
});
|
||
|
||
// chat:message — 新消息
|
||
window.addEventListener("chat:message", (e) => {
|
||
const msg = e.detail;
|
||
if (msg.is_secret && msg.from_user !== window.chatContext?.username && msg.to_user !== window.chatContext?.username) {
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
|
||
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
|
||
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
|
||
const t = msg.toast_notification;
|
||
window.chatToast?.show({
|
||
title: t.title || "通知",
|
||
message: t.message || "",
|
||
icon: t.icon || "💬",
|
||
color: t.color || "#336699",
|
||
duration: t.duration ?? 8000,
|
||
});
|
||
}
|
||
});
|
||
|
||
// chat:kicked — 被踢出房间
|
||
window.addEventListener("chat:kicked", (e) => {
|
||
if (e.detail.username === window.chatContext?.username) {
|
||
const roomsIndexUrl = window.chatContext?.roomsIndexUrl || "/rooms";
|
||
window.chatDialog?.alert(
|
||
"您已被管理员踢出房间!" + (e.detail.reason ? " 原因:" + e.detail.reason : ""),
|
||
"系统通知",
|
||
"#cc4444"
|
||
);
|
||
window.location.href = roomsIndexUrl;
|
||
}
|
||
});
|
||
|
||
// chat:muted — 禁言事件
|
||
window.addEventListener("chat:muted", handleMutedEvent);
|
||
|
||
// chat:title-updated — 房间标题更新
|
||
window.addEventListener("chat:title-updated", (e) => {
|
||
const display = document.getElementById("room-title-display");
|
||
if (display) display.innerText = e.detail.title;
|
||
});
|
||
|
||
// chat:browser-refresh-requested — 全员刷新通知
|
||
window.addEventListener("chat:browser-refresh-requested", (e) => {
|
||
const detail = e.detail || {};
|
||
const operatorName = escapeHtml(String(detail.operator || "站长"));
|
||
const reasonText = escapeHtml(String(detail.reason || "页面功能已更新,请重新载入。"));
|
||
|
||
window.chatToast?.show({
|
||
title: "页面即将刷新",
|
||
message: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
|
||
icon: "♻️",
|
||
color: "#0f766e",
|
||
duration: 2200,
|
||
});
|
||
|
||
window.setTimeout(() => {
|
||
window.location.reload();
|
||
}, 900);
|
||
});
|
||
|
||
// chat:user-browser-refresh-requested — 目标用户定向刷新
|
||
window.addEventListener("chat:user-browser-refresh-requested", (e) => {
|
||
const detail = e.detail || {};
|
||
const operatorName = escapeHtml(String(detail.operator || "管理员"));
|
||
const reasonText = escapeHtml(String(detail.reason || "你的权限状态已发生变化,页面即将刷新。"));
|
||
|
||
window.chatToast?.show({
|
||
title: "权限同步中",
|
||
message: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
|
||
icon: "🔄",
|
||
color: "#7c3aed",
|
||
duration: 2600,
|
||
});
|
||
|
||
window.setTimeout(() => {
|
||
window.location.reload();
|
||
}, 1000);
|
||
});
|
||
|
||
// chat:effect — 全屏特效事件
|
||
window.addEventListener("chat:effect", (e) => {
|
||
const type = e.detail?.type;
|
||
const target = e.detail?.target_username;
|
||
const operator = e.detail?.operator;
|
||
const myName = window.chatContext?.username;
|
||
|
||
if (type && typeof EffectManager !== "undefined") {
|
||
if (!target || target === myName || operator === myName) {
|
||
EffectManager.play(type);
|
||
}
|
||
}
|
||
});
|
||
|
||
// chat:pat — 拍一拍事件
|
||
window.addEventListener("chat:pat", (e) => {
|
||
const { from_user, target_user, display_text, from_user_headface } = e.detail || {};
|
||
if (!display_text) return;
|
||
|
||
if (typeof window.appendPatMessage === "function") {
|
||
window.appendPatMessage(display_text, from_user_headface, from_user, target_user);
|
||
}
|
||
if (typeof window.triggerPatShake === "function") {
|
||
window.triggerPatShake();
|
||
}
|
||
});
|
||
|
||
// chat:idiom-started — 猜成语出题
|
||
window.addEventListener("chat:idiom-started", (e) => {
|
||
if (typeof window.handleIdiomGameStarted === "function") {
|
||
window.handleIdiomGameStarted(e);
|
||
}
|
||
});
|
||
|
||
// chat:idiom-answered — 猜成语答题结果
|
||
window.addEventListener("chat:idiom-answered", (e) => {
|
||
if (typeof window.handleIdiomGameAnswered === "function") {
|
||
window.handleIdiomGameAnswered(e);
|
||
}
|
||
});
|
||
|
||
// Echo 级监听器(延迟绑定,等待 Echo 就绪)
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
setupScreenClearedListener();
|
||
setupRoomBrowserRefreshListener();
|
||
setupChangelogPublishedListener();
|
||
setupGomokuInviteListener();
|
||
});
|
||
}
|
||
|
||
export { initChatWebSocket };
|