Files
chatroom/resources/js/chat-room/chat-events.js
T
2026-04-30 11:07:46 +08:00

536 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 聊天室 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;
let chatWebSocketInitRetryTimer = null;
const GOMOKU_INVITE_BUTTON_FONT_SIZE = "0.82em";
// ── 辅助函数 ──
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
function getState() {
return window.chatState;
}
/**
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/
function initChatWebSocket() {
if (chatWebSocketInitRetryTimer) {
window.clearTimeout(chatWebSocketInitRetryTimer);
chatWebSocketInitRetryTimer = null;
}
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
return;
}
// chat.js 会在模块末尾才把 initChat 挂到 window;若这里抢先执行,稍后自动补一次初始化。
chatWebSocketInitRetryTimer = window.setTimeout(() => {
chatWebSocketInitRetryTimer = null;
initChatWebSocket();
}, 100);
}
/**
* 在 DOM 已就绪时立即执行回调,避免 Vite 模块晚于 DOMContentLoaded 执行时漏掉初始化。
*
* @param {() => void} callback 页面就绪后的回调
* @returns {void}
*/
function runWhenDomReady(callback) {
if (typeof callback !== "function") {
return;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
return;
}
callback();
}
// ── 禁言逻辑 ──
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:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
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:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
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;
// 页面已完成解析时立刻补做初始化,确保 Presence 连接不会因为错过 DOMContentLoaded 而丢失。
runWhenDomReady(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.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;
const effectOptions = {
effect_title: e.detail?.effect_title,
effect_user_info: e.detail?.effect_user_info,
ride_name: e.detail?.ride_name,
operator,
};
if (type && typeof EffectManager !== "undefined") {
if (!target || target === myName || operator === myName) {
EffectManager.play(type, effectOptions);
}
}
});
// 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.handleRiddleGameStarted === "function") {
window.handleRiddleGameStarted(e);
}
});
// chat:idiom-answered — 猜成语答题结果
window.addEventListener("chat:idiom-answered", (e) => {
if (typeof window.handleRiddleGameAnswered === "function") {
window.handleRiddleGameAnswered(e);
}
});
// Echo 级监听器同样要兼容“脚本加载时页面已完成”的场景。
runWhenDomReady(() => {
setupScreenClearedListener();
setupRoomBrowserRefreshListener();
setupChangelogPublishedListener();
setupGomokuInviteListener();
});
}
export { initChatWebSocket };