530 lines
21 KiB
JavaScript
530 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;
|
||
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;
|
||
|
||
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.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 };
|