diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js new file mode 100644 index 0000000..d3d298b --- /dev/null +++ b/resources/js/chat-room.js @@ -0,0 +1,69 @@ +// 聊天室 Vite 入口,集中导出从 Blade 内联脚本迁移出的纯前端工具。 + +export { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; +export { applyFontSize, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; +export { closeChatImageLightbox, openChatImageLightbox } from "./chat-room/lightbox.js"; +export { + BLOCKABLE_SYSTEM_SENDERS, + BLOCKED_SYSTEM_SENDERS_STORAGE_KEY, + CHAT_SOUND_MUTED_STORAGE_KEY, + normalizeChatPreferences, + normalizeDailyStatus, + parseDailyStatusExpiry, +} from "./chat-room/preferences-status.js"; +export { + normalizeRoomStatus, + renderRoomStatusRow, + renderRoomsOnlineStatus, + renderRoomsOnlineStatusToContainer, + resolveRoomUrl, +} from "./chat-room/rooms.js"; +export { createMessageQueue } from "./chat-room/message-queue.js"; + +import { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; +import { applyFontSize, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; +import { closeChatImageLightbox, openChatImageLightbox } from "./chat-room/lightbox.js"; +import { + BLOCKABLE_SYSTEM_SENDERS, + BLOCKED_SYSTEM_SENDERS_STORAGE_KEY, + CHAT_SOUND_MUTED_STORAGE_KEY, + normalizeChatPreferences, + normalizeDailyStatus, + parseDailyStatusExpiry, +} from "./chat-room/preferences-status.js"; +import { + normalizeRoomStatus, + renderRoomStatusRow, + renderRoomsOnlineStatus, + renderRoomsOnlineStatusToContainer, + resolveRoomUrl, +} from "./chat-room/rooms.js"; +import { createMessageQueue } from "./chat-room/message-queue.js"; + +if (typeof window !== "undefined") { + window.ChatRoomTools = { + escapeHtml, + escapeHtmlWithLineBreaks, + applyFontSize, + CHAT_FONT_SIZE_STORAGE_KEY, + restoreChatFontSize, + closeChatImageLightbox, + openChatImageLightbox, + BLOCKABLE_SYSTEM_SENDERS, + BLOCKED_SYSTEM_SENDERS_STORAGE_KEY, + CHAT_SOUND_MUTED_STORAGE_KEY, + normalizeChatPreferences, + normalizeDailyStatus, + parseDailyStatusExpiry, + normalizeRoomStatus, + renderRoomStatusRow, + renderRoomsOnlineStatus, + renderRoomsOnlineStatusToContainer, + resolveRoomUrl, + createMessageQueue, + }; + + window.closeChatImageLightbox = closeChatImageLightbox; + window.openChatImageLightbox = openChatImageLightbox; + window.applyFontSize = applyFontSize; +} diff --git a/resources/js/chat-room/font-size.js b/resources/js/chat-room/font-size.js new file mode 100644 index 0000000..238654e --- /dev/null +++ b/resources/js/chat-room/font-size.js @@ -0,0 +1,45 @@ +// 聊天室字号偏好控制,保留旧的 localStorage key 以兼容已有用户设置。 + +export const CHAT_FONT_SIZE_STORAGE_KEY = "chat_font_size"; + +/** + * 应用字号到聊天消息窗口,并保存到 localStorage。 + * + * @param {string|number} size 字号大小 + * @returns {boolean} + */ +export function applyFontSize(size) { + const px = Number.parseInt(size, 10); + if (Number.isNaN(px) || px < 10 || px > 30) { + return false; + } + + const publicContainer = document.getElementById("chat-messages-container"); + const privateContainer = document.getElementById("chat-messages-container2"); + if (publicContainer) { + publicContainer.style.fontSize = `${px}px`; + } + if (privateContainer) { + privateContainer.style.fontSize = `${px}px`; + } + + localStorage.setItem(CHAT_FONT_SIZE_STORAGE_KEY, String(px)); + + const selector = document.getElementById("font_size_select"); + if (selector) { + selector.value = String(px); + } + + return true; +} + +/** + * 从 localStorage 恢复已保存的聊天室字号。 + * + * @returns {boolean} + */ +export function restoreChatFontSize() { + const saved = localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY); + + return saved ? applyFontSize(saved) : false; +} diff --git a/resources/js/chat-room/html.js b/resources/js/chat-room/html.js new file mode 100644 index 0000000..63120f0 --- /dev/null +++ b/resources/js/chat-room/html.js @@ -0,0 +1,29 @@ +// 聊天室前端 HTML 安全工具,供 Blade 内联脚本迁移到 Vite 后复用。 + +const HTML_ESCAPE_MAP = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +/** + * 转义可被拼入 innerHTML 的动态文本。 + * + * @param {unknown} value + * @returns {string} + */ +export function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char]); +} + +/** + * 转义多行文本,并保留换行展示。 + * + * @param {unknown} value + * @returns {string} + */ +export function escapeHtmlWithLineBreaks(value) { + return escapeHtml(value).replace(/\n/g, "
"); +} diff --git a/resources/js/chat-room/lightbox.js b/resources/js/chat-room/lightbox.js new file mode 100644 index 0000000..c8430b5 --- /dev/null +++ b/resources/js/chat-room/lightbox.js @@ -0,0 +1,54 @@ +// 聊天室图片预览层控制,迁出 Blade 内联脚本后仍通过 window 全局函数兼容现有 onclick。 + +/** + * 打开聊天图片大图预览层。 + * + * @param {string} imageUrl 图片地址 + * @param {string} imageName 图片名称 + * @returns {void} + */ +export function openChatImageLightbox(imageUrl, imageName = "聊天图片") { + const lightbox = document.getElementById("chat-image-lightbox"); + const imageEl = document.getElementById("chat-image-lightbox-img"); + const nameEl = document.getElementById("chat-image-lightbox-name"); + + if (!lightbox || !imageEl || !imageUrl) { + return; + } + + imageEl.src = imageUrl; + imageEl.alt = imageName; + + if (nameEl) { + nameEl.textContent = imageName; + } + + lightbox.style.display = "block"; + document.body.style.overflow = "hidden"; +} + +/** + * 关闭聊天图片大图预览层。 + * + * @param {Event|null} event 点击事件 + * @returns {void} + */ +export function closeChatImageLightbox(event = null) { + if (event && event.target !== event.currentTarget) { + return; + } + + const lightbox = document.getElementById("chat-image-lightbox"); + if (!lightbox) { + return; + } + + lightbox.style.display = "none"; + + const imageEl = document.getElementById("chat-image-lightbox-img"); + if (imageEl) { + imageEl.src = ""; + } + + document.body.style.overflow = ""; +} diff --git a/resources/js/chat-room/message-queue.js b/resources/js/chat-room/message-queue.js new file mode 100644 index 0000000..06349b9 --- /dev/null +++ b/resources/js/chat-room/message-queue.js @@ -0,0 +1,63 @@ +// 聊天室消息队列工具,用于后续迁移接收消息、批量渲染和节流刷新逻辑。 + +/** + * 创建可控长度的消息队列。 + * + * @param {{limit?:number, onFlush?:(messages:unknown[])=>void, scheduler?:(callback:()=>void)=>unknown}} options + * @returns {{enqueue:(message:unknown)=>number, flush:()=>unknown[], scheduleFlush:()=>void, clear:()=>void, size:()=>number, toArray:()=>unknown[]}} + */ +export function createMessageQueue(options = {}) { + const limit = Math.max(Number.parseInt(options.limit, 10) || 200, 1); + const queue = []; + let flushScheduled = false; + + const scheduler = typeof options.scheduler === "function" + ? options.scheduler + : (callback) => { + const requestFrame = globalThis.requestAnimationFrame || ((handler) => globalThis.setTimeout(handler, 16)); + + return requestFrame(callback); + }; + + const flush = () => { + flushScheduled = false; + const messages = queue.splice(0, queue.length); + + if (messages.length && typeof options.onFlush === "function") { + options.onFlush(messages); + } + + return messages; + }; + + return { + enqueue(message) { + queue.push(message); + + while (queue.length > limit) { + queue.shift(); + } + + return queue.length; + }, + flush, + scheduleFlush() { + if (flushScheduled) { + return; + } + + flushScheduled = true; + scheduler(flush); + }, + clear() { + queue.splice(0, queue.length); + flushScheduled = false; + }, + size() { + return queue.length; + }, + toArray() { + return [...queue]; + }, + }; +} diff --git a/resources/js/chat-room/preferences-status.js b/resources/js/chat-room/preferences-status.js new file mode 100644 index 0000000..5a861e6 --- /dev/null +++ b/resources/js/chat-room/preferences-status.js @@ -0,0 +1,75 @@ +// 聊天室偏好与每日状态工具,承接从 Blade 内联脚本迁移出的纯数据规整逻辑。 + +export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"]; +export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders"; +export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted"; + +/** + * 规整聊天室偏好对象,过滤非法配置并补齐默认值。 + * + * @param {Record|null|undefined} raw + * @param {string[]} blockableSystemSenders + * @returns {{blocked_system_senders:string[],sound_muted:boolean}} + */ +export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) { + const blocked = Array.isArray(raw?.blocked_system_senders) + ? raw.blocked_system_senders.filter((sender) => blockableSystemSenders.includes(sender)) + : []; + + return { + blocked_system_senders: Array.from(new Set(blocked)), + sound_muted: Boolean(raw?.sound_muted), + }; +} + +/** + * 解析并标准化状态到期时间。 + * + * @param {string|null|undefined} expiresAt + * @returns {Date|null} + */ +export function parseDailyStatusExpiry(expiresAt) { + if (!expiresAt) { + return null; + } + + const parsed = new Date(expiresAt); + + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * 将状态对象规整为前端统一结构,并过滤掉已过期状态。 + * + * @param {Record|null|undefined} raw + * @param {number} nowTimestamp + * @returns {{key:string,label:string,icon:string,group:string,expires_at:string}|null} + */ +export function normalizeDailyStatus(raw, nowTimestamp = Date.now()) { + if (!raw || typeof raw !== "object") { + return null; + } + + const key = String(raw.key ?? raw.daily_status_key ?? ""); + const label = String(raw.label ?? raw.daily_status_label ?? ""); + const icon = String(raw.icon ?? raw.daily_status_icon ?? ""); + const group = String(raw.group ?? raw.daily_status_group ?? ""); + const expiresAt = raw.expires_at ?? raw.daily_status_expires_at ?? null; + const parsedExpiry = parseDailyStatusExpiry(expiresAt); + + if (!key || !label || !icon || !parsedExpiry) { + return null; + } + + if (parsedExpiry.getTime() <= nowTimestamp) { + return null; + } + + return { + key, + label, + icon, + group, + expires_at: parsedExpiry.toISOString(), + }; +} diff --git a/resources/js/chat-room/rooms.js b/resources/js/chat-room/rooms.js new file mode 100644 index 0000000..548689c --- /dev/null +++ b/resources/js/chat-room/rooms.js @@ -0,0 +1,133 @@ +// 聊天室房间在线状态渲染工具,抽离右侧主面板与手机抽屉可共用的纯前端逻辑。 + +import { escapeHtml } from "./html.js"; + +const EMPTY_ROOMS_HTML = + '
暂无房间
'; + +/** + * 转换接口房间数据,过滤异常房间编号。 + * + * @param {Record} room + * @returns {{id:number,name:string,online:number,doorOpen:boolean}|null} + */ +export function normalizeRoomStatus(room) { + const roomId = Number.parseInt(room?.id, 10); + + if (!Number.isInteger(roomId)) { + return null; + } + + return { + id: roomId, + name: String(room?.name ?? ""), + online: Math.max(Number.parseInt(room?.online, 10) || 0, 0), + doorOpen: Boolean(room?.door_open), + }; +} + +/** + * 生成房间跳转地址,默认保持现有 `/room/{id}` 路径。 + * + * @param {number} roomId + * @param {(roomId:number) => string} [roomUrlResolver] + * @returns {string} + */ +export function resolveRoomUrl(roomId, roomUrlResolver = undefined) { + return typeof roomUrlResolver === "function" ? String(roomUrlResolver(roomId)) : `/room/${roomId}`; +} + +/** + * 生成可放入 onclick 属性的安全跳转语句。 + * + * @param {number} roomId + * @param {(roomId:number) => string} [roomUrlResolver] + * @returns {string} + */ +function buildRoomClickHandler(roomId, roomUrlResolver = undefined) { + const safeUrlLiteral = escapeHtml(JSON.stringify(resolveRoomUrl(roomId, roomUrlResolver))); + + return `onclick="location.href=${safeUrlLiteral}"`; +} + +/** + * 渲染单个房间在线状态行。 + * + * @param {{id:number,name:string,online:number,doorOpen:boolean}} room + * @param {{currentRoomId?:number|null, variant?:'desktop'|'mobile', roomUrlResolver?:(roomId:number)=>string}} options + * @returns {string} + */ +export function renderRoomStatusRow(room, options = {}) { + const currentRoomId = Number.parseInt(options.currentRoomId, 10); + const isCurrent = Number.isInteger(currentRoomId) && room.id === currentRoomId; + const variant = options.variant === "mobile" ? "mobile" : "desktop"; + const safeRoomName = escapeHtml(room.name); + const bg = isCurrent ? "#ecf4ff" : "#fff"; + const nameColor = isCurrent ? "#336699" : (room.doorOpen ? "#444" : "#bbb"); + const currentTag = isCurrent + ? (variant === "mobile" + ? '当前' + : '当前') + : ""; + const clickHandler = isCurrent ? "" : buildRoomClickHandler(room.id, options.roomUrlResolver); + const badge = room.online > 0 + ? `${room.online}${variant === "mobile" ? "" : " "}人` + : ``; + + if (variant === "mobile") { + return `
+ + ${safeRoomName}${currentTag} + ${badge} +
`; + } + + const border = isCurrent ? "#aac5f0" : "#e0eaf5"; + + return `
+ + ${safeRoomName}${currentTag} + + ${badge} +
`; +} + +/** + * 渲染房间在线状态列表 HTML。 + * + * @param {{rooms?: Array>}} data + * @param {{currentRoomId?:number|null, variant?:'desktop'|'mobile', emptyHtml?:string, roomUrlResolver?:(roomId:number)=>string}} options + * @returns {string} + */ +export function renderRoomsOnlineStatus(data, options = {}) { + const rooms = Array.isArray(data?.rooms) ? data.rooms : []; + const rows = rooms + .map((room) => normalizeRoomStatus(room)) + .filter(Boolean) + .map((room) => renderRoomStatusRow(room, options)) + .join(""); + + return rows || options.emptyHtml || EMPTY_ROOMS_HTML; +} + +/** + * 将房间在线状态列表渲染到指定容器。 + * + * @param {{rooms?: Array>}} data + * @param {HTMLElement} container + * @param {{currentRoomId?:number|null, variant?:'desktop'|'mobile', emptyHtml?:string, roomUrlResolver?:(roomId:number)=>string}} options + * @returns {void} + */ +export function renderRoomsOnlineStatusToContainer(data, container, options = {}) { + container.innerHTML = renderRoomsOnlineStatus(data, options); +} diff --git a/resources/js/chat.js b/resources/js/chat.js index 86e7fa2..47b6278 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -1,4 +1,5 @@ import "./bootstrap"; +import "./chat-room.js"; // 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。 // 通过 Presence Channel 实现聊天室的核心监听。 diff --git a/resources/views/chat/partials/layout/mobile-drawer.blade.php b/resources/views/chat/partials/layout/mobile-drawer.blade.php index 4dcd910..953c0f7 100644 --- a/resources/views/chat/partials/layout/mobile-drawer.blade.php +++ b/resources/views/chat/partials/layout/mobile-drawer.blade.php @@ -83,7 +83,7 @@ + oninput="scheduleRenderMobileUserList()"> {{-- 用户列表容器 --}} @@ -127,6 +127,10 @@ * @type {string|null} */ let _mobileDrawerOpen = null; + let _mobileUserListRenderTimer = null; + let _mobileRoomsOnlineStatusCache = null; + let _mobileRoomsOnlineStatusCacheAt = 0; + const MOBILE_ROOMS_ONLINE_STATUS_CACHE_TTL = 10000; /** * 打开指定抽屉 @@ -228,6 +232,21 @@ if (footerEl) footerEl.textContent = count; } + /** + * 调度手机端在线名单渲染,避免搜索输入时同步重建整份名单。 + */ + function scheduleRenderMobileUserList() { + if (_mobileUserListRenderTimer !== null) { + return; + } + + const scheduleRender = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16)); + _mobileUserListRenderTimer = scheduleRender(() => { + _mobileUserListRenderTimer = null; + renderMobileUserList(); + }); + } + /** * 拉取房间列表并渲染到手机端房间容器 */ @@ -235,34 +254,64 @@ const container = document.getElementById('mob-rooms-online-list'); if (!container) return; + if (_mobileRoomsOnlineStatusCache && Date.now() - _mobileRoomsOnlineStatusCacheAt < MOBILE_ROOMS_ONLINE_STATUS_CACHE_TTL) { + renderMobileRoomList(_mobileRoomsOnlineStatusCache, container); + return; + } + container.innerHTML = '
加载中...
'; fetch('{{ route('chat.rooms-online-status') }}') .then(r => r.json()) .then(data => { - if (!data.rooms || !data.rooms.length) { - container.innerHTML = '
暂无房间
'; - return; - } - const currentRoomId = window.chatContext?.roomId; - const roomRows = data.rooms.map(room => { - const roomId = Number.parseInt(room.id, 10); - if (!Number.isInteger(roomId)) { - return ''; - } + _mobileRoomsOnlineStatusCache = data; + _mobileRoomsOnlineStatusCacheAt = Date.now(); + renderMobileRoomList(data, container); + }) + .catch(() => { + container.innerHTML = '
加载失败
'; + }); + } - const isCurrent = roomId === currentRoomId; - const bg = isCurrent ? '#ecf4ff' : '#fff'; - const nameColor = isCurrent ? '#336699' : (room.door_open ? '#444' : '#bbb'); - const safeRoomName = escapeMobileDrawerHtml(String(room.name ?? '')); - const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0); - const badge = safeOnlineCount > 0 - ? `${safeOnlineCount}人` - : ``; - const currentTag = isCurrent ? `当前` : ''; - const clickAttr = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`; + /** + * 渲染手机端房间列表。 + * + * @param {Object} data 接口返回数据 + * @param {HTMLElement} container 目标容器 + */ + function renderMobileRoomList(data, container) { + if (window.ChatRoomTools?.renderRoomsOnlineStatusToContainer) { + window.ChatRoomTools.renderRoomsOnlineStatusToContainer(data, container, { + currentRoomId: window.chatContext?.roomId, + variant: 'mobile', + emptyHtml: '
暂无房间
', + }); + return; + } - return `
{ + const roomId = Number.parseInt(room.id, 10); + if (!Number.isInteger(roomId)) { + return ''; + } + + const isCurrent = roomId === currentRoomId; + const bg = isCurrent ? '#ecf4ff' : '#fff'; + const nameColor = isCurrent ? '#336699' : (room.door_open ? '#444' : '#bbb'); + const safeRoomName = escapeMobileDrawerHtml(String(room.name ?? '')); + const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0); + const badge = safeOnlineCount > 0 + ? `${safeOnlineCount}人` + : ``; + const currentTag = isCurrent ? `当前` : ''; + const clickAttr = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`; + + return `
@@ -270,13 +319,9 @@ ${safeRoomName}${currentTag} ${badge}
`; - }).filter(Boolean).join(''); + }).filter(Boolean).join(''); - container.innerHTML = roomRows || '
暂无房间
'; - }) - .catch(() => { - container.innerHTML = '
加载失败
'; - }); + container.innerHTML = roomRows || '
暂无房间
'; } /** diff --git a/resources/views/chat/partials/layout/right-panel.blade.php b/resources/views/chat/partials/layout/right-panel.blade.php index 4b3a3c4..7ffa8e2 100644 --- a/resources/views/chat/partials/layout/right-panel.blade.php +++ b/resources/views/chat/partials/layout/right-panel.blade.php @@ -37,7 +37,7 @@
-
diff --git a/resources/views/chat/partials/marriage-modals.blade.php b/resources/views/chat/partials/marriage-modals.blade.php index d8575e7..e309460 100644 --- a/resources/views/chat/partials/marriage-modals.blade.php +++ b/resources/views/chat/partials/marriage-modals.blade.php @@ -1420,8 +1420,8 @@ } // ── 页面刷新后恢复婚礼红包领取按钮 ───────────────────────── - // 延迟 2 秒以确保聊天框和 Alpine 均已完成初始化 - setTimeout(async () => { + // 空闲时再查待领取红包,避免和聊天室首屏消息/名单初始化抢网络。 + window.deferChatGameBootstrap(async () => { try { const res = await fetch('/wedding/pending-envelopes', { headers: { @@ -1467,6 +1467,6 @@ } catch (e) { console.warn('[婚礼红包] 恢复待领取按钮失败', e); } - }, 2000); + }, 3000); }); diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 2e9e6c1..ff537f8 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -37,9 +37,9 @@ const toUserSelect = document.getElementById('to_user'); const onlineCount = document.getElementById('online-count'); const onlineCountBottom = document.getElementById('online-count-bottom'); - const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = 'chat_blocked_system_senders'; - const CHAT_SOUND_MUTED_STORAGE_KEY = 'chat_sound_muted'; - const BLOCKABLE_SYSTEM_SENDERS = ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子']; + const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = window.ChatRoomTools?.BLOCKED_SYSTEM_SENDERS_STORAGE_KEY || 'chat_blocked_system_senders'; + const CHAT_SOUND_MUTED_STORAGE_KEY = window.ChatRoomTools?.CHAT_SOUND_MUTED_STORAGE_KEY || 'chat_sound_muted'; + const BLOCKABLE_SYSTEM_SENDERS = window.ChatRoomTools?.BLOCKABLE_SYSTEM_SENDERS || ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子']; const hoverTooltip = document.getElementById('chat-hover-tooltip'); let activeTooltipTrigger = null; @@ -75,6 +75,13 @@ let userBadgeRotationTick = 0; let userListRenderTimer = null; let _maxMsgId = 0; // 记录当前收到的最大消息 ID + let pendingChatMessages = []; + let chatMessageFlushTimer = null; + let userFilterRenderTimer = null; + let lastAutosaveNode = null; + const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8; + const PUBLIC_MESSAGE_NODE_LIMIT = 600; + const PRIVATE_MESSAGE_NODE_LIMIT = 300; const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {}); let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders); @@ -85,6 +92,10 @@ * @returns {Object} */ function normalizeChatPreferences(raw) { + if (window.ChatRoomTools?.normalizeChatPreferences) { + return window.ChatRoomTools.normalizeChatPreferences(raw, BLOCKABLE_SYSTEM_SENDERS); + } + const blocked = Array.isArray(raw?.blocked_system_senders) ? raw.blocked_system_senders.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender)) : []; @@ -103,6 +114,10 @@ * @returns {Date|null} */ function parseDailyStatusExpiry(expiresAt) { + if (window.ChatRoomTools?.parseDailyStatusExpiry) { + return window.ChatRoomTools.parseDailyStatusExpiry(expiresAt); + } + if (!expiresAt) { return null; } @@ -119,6 +134,10 @@ * @returns {Object|null} */ function normalizeDailyStatus(raw) { + if (window.ChatRoomTools?.normalizeDailyStatus) { + return window.ChatRoomTools.normalizeDailyStatus(raw); + } + if (!raw || typeof raw !== 'object') { return null; } @@ -1459,6 +1478,9 @@ // ── Tab 切换 ────────────────────────────────────── let _roomsRefreshTimer = null; + let _roomsOnlineStatusCache = null; + let _roomsOnlineStatusCacheAt = 0; + const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000; function switchTab(tab) { // 切换名单/房间 面板 @@ -1470,7 +1492,7 @@ if (tab === 'rooms') { loadRoomsOnlineStatus(); clearInterval(_roomsRefreshTimer); - _roomsRefreshTimer = setInterval(loadRoomsOnlineStatus, 30000); + _roomsRefreshTimer = setInterval(() => loadRoomsOnlineStatus(true), 30000); } else { clearInterval(_roomsRefreshTimer); _roomsRefreshTimer = null; @@ -1482,42 +1504,72 @@ */ const _currentRoomId = {{ $room->id }}; - function loadRoomsOnlineStatus() { + function loadRoomsOnlineStatus(forceRefresh = false) { const container = document.getElementById('rooms-online-list'); if (!container) { return; } + if (!forceRefresh && _roomsOnlineStatusCache && Date.now() - _roomsOnlineStatusCacheAt < ROOMS_ONLINE_STATUS_CACHE_TTL) { + renderRoomsOnlineStatus(_roomsOnlineStatusCache, container); + return; + } + fetch('{{ route('chat.rooms-online-status') }}') .then(r => r.json()) .then(data => { - if (!data.rooms || !data.rooms.length) { - container.innerHTML = - '
暂无房间
'; - return; - } - const roomRows = data.rooms.map(room => { - const roomId = Number.parseInt(room.id, 10); - if (!Number.isInteger(roomId)) { - return ''; - } + _roomsOnlineStatusCache = data; + _roomsOnlineStatusCacheAt = Date.now(); + renderRoomsOnlineStatus(data, container); + }) + .catch(() => { + container.innerHTML = + '
加载失败
'; + }); + } - const isCurrent = roomId === _currentRoomId; - const closed = !room.door_open; - const safeRoomName = escapeHtml(String(room.name ?? '')); - const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0); - const bg = isCurrent ? '#ecf4ff' : '#fff'; - const border = isCurrent ? '#aac5f0' : '#e0eaf5'; - const nameColor = isCurrent ? '#336699' : (closed ? '#bbb' : '#444'); - const badge = safeOnlineCount > 0 ? - `${safeOnlineCount} 人` : - ``; - const currentTag = isCurrent ? - `当前` : - ''; - const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`; + /** + * 渲染房间在线状态列表。 + * + * @param {Object} data 接口返回数据 + * @param {HTMLElement} container 目标容器 + */ + function renderRoomsOnlineStatus(data, container) { + if (window.ChatRoomTools?.renderRoomsOnlineStatusToContainer) { + window.ChatRoomTools.renderRoomsOnlineStatusToContainer(data, container, { + currentRoomId: _currentRoomId, + variant: 'desktop', + }); + return; + } - return `
暂无房间
'; + return; + } + const roomRows = data.rooms.map(room => { + const roomId = Number.parseInt(room.id, 10); + if (!Number.isInteger(roomId)) { + return ''; + } + + const isCurrent = roomId === _currentRoomId; + const closed = !room.door_open; + const safeRoomName = escapeHtml(String(room.name ?? '')); + const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0); + const bg = isCurrent ? '#ecf4ff' : '#fff'; + const border = isCurrent ? '#aac5f0' : '#e0eaf5'; + const nameColor = isCurrent ? '#336699' : (closed ? '#bbb' : '#444'); + const badge = safeOnlineCount > 0 ? + `${safeOnlineCount} 人` : + ``; + const currentTag = isCurrent ? + `当前` : + ''; + const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`; + + return `
暂无房间
'; - }) - .catch(() => { - container.innerHTML = - '
加载失败
'; - }); + container.innerHTML = roomRows || + '
暂无房间
'; } @@ -2215,11 +2262,34 @@ // 名单中多种徽标轮换展示时,每 3 秒只刷新徽标槽位,不重建头像行。 window.setInterval(() => { + if (document.hidden) { + return; + } + userBadgeRotationTick = (userBadgeRotationTick + 1) % 4; - refreshRenderedUserBadges(); + refreshRenderedUserBadges(userList); + const mobileUsersList = document.getElementById('mob-online-users-list'); + if (mobileUsersList?.offsetParent !== null) { + refreshRenderedUserBadges(mobileUsersList); + } syncDailyStatusUi(); }, 3000); + /** + * 调度用户列表搜索过滤,避免每个按键都同步扫描名单 DOM。 + */ + function scheduleFilterUserList() { + if (userFilterRenderTimer !== null) { + return; + } + + const scheduleFilter = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16)); + userFilterRenderTimer = scheduleFilter(() => { + userFilterRenderTimer = null; + filterUserList(); + }); + } + /** * 搜索/过滤用户列表 */ @@ -2246,7 +2316,7 @@ /** * 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示) */ - function appendMessage(msg) { + function appendMessage(msg, renderBatch = null) { // 记录拉取到的最大消息ID,用于本地清屏功能 if (msg && msg.id > _maxMsgId) { _maxMsgId = msg.id; @@ -2524,8 +2594,11 @@ if (msg.welcome_user) { div.setAttribute('data-system-user', msg.welcome_user); // 收到后端来的新欢迎消息时,把界面上该用户旧的都删掉 - const oldWelcomes = container.querySelectorAll(`[data-system-user="${msg.welcome_user}"]`); + const welcomeSelector = `[data-system-user="${msg.welcome_user}"]`; + const oldWelcomes = container.querySelectorAll(welcomeSelector); oldWelcomes.forEach(el => el.remove()); + renderBatch?.publicFragment.querySelectorAll(welcomeSelector).forEach(el => el.remove()); + renderBatch?.privateFragment.querySelectorAll(welcomeSelector).forEach(el => el.remove()); } // 路由规则(复刻原版): @@ -2545,18 +2618,136 @@ if (isRelatedToMe) { // 删除旧的存点通知,保持包厢窗口整洁 if (isAutoSave) { - container2.querySelectorAll('[data-autosave="1"]').forEach(el => el.remove()); + lastAutosaveNode?.remove(); + lastAutosaveNode = div; + } + if (renderBatch) { + renderBatch.privateFragment.appendChild(div); + renderBatch.shouldPrunePrivate = true; + renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || autoScroll; + return; } container2.appendChild(div); + pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT); if (autoScroll) { container2.scrollTop = container2.scrollHeight; } } else { + if (renderBatch) { + renderBatch.publicFragment.appendChild(div); + renderBatch.shouldPrunePublic = true; + renderBatch.shouldScrollPublic = renderBatch.shouldScrollPublic || autoScroll; + return; + } container.appendChild(div); + pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT); scrollToBottom(); } } + /** + * 裁剪聊天窗口内过旧的消息节点,防止长时间在线后 DOM 无限膨胀。 + * + * @param {HTMLElement} targetContainer 聊天窗口容器 + * @param {number} maxNodes 最大保留节点数 + */ + function pruneMessageContainer(targetContainer, maxNodes) { + if (!targetContainer || targetContainer.childElementCount <= maxNodes) { + return; + } + + while (targetContainer.childElementCount > maxNodes) { + const firstNode = targetContainer.firstElementChild; + if (firstNode === lastAutosaveNode) { + lastAutosaveNode = null; + } + firstNode?.remove(); + } + } + + /** + * 创建聊天消息批量渲染上下文,集中提交 DOM 变更。 + * + * @returns {Object} + */ + function createChatMessageRenderBatch() { + return { + publicFragment: document.createDocumentFragment(), + privateFragment: document.createDocumentFragment(), + shouldPrunePublic: false, + shouldPrunePrivate: false, + shouldScrollPublic: false, + shouldScrollPrivate: false, + }; + } + + /** + * 提交批量渲染的聊天消息,并把裁剪和滚动合并到每批一次。 + * + * @param {Object} renderBatch 批量渲染上下文 + */ + function commitChatMessageRenderBatch(renderBatch) { + const hasPublicMessages = renderBatch.publicFragment.childNodes.length > 0; + const hasPrivateMessages = renderBatch.privateFragment.childNodes.length > 0; + + if (hasPublicMessages) { + container.appendChild(renderBatch.publicFragment); + } + if (hasPrivateMessages) { + container2.appendChild(renderBatch.privateFragment); + } + if (renderBatch.shouldPrunePublic) { + pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT); + } + if (renderBatch.shouldPrunePrivate) { + pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT); + } + if (renderBatch.shouldScrollPublic) { + container.scrollTop = container.scrollHeight; + } + if (renderBatch.shouldScrollPrivate) { + container2.scrollTop = container2.scrollHeight; + } + } + + /** + * 将广播消息放入轻量队列,避免连续广播时同步挤占同一帧的 DOM 渲染。 + */ + function enqueueChatMessage(msg) { + // 本地清屏依赖最大消息 ID,需要在进入队列时先同步,避免延后渲染导致状态滞后。 + if (msg && msg.id > _maxMsgId) { + _maxMsgId = msg.id; + } + + pendingChatMessages.push(msg); + + if (chatMessageFlushTimer !== null) { + return; + } + + const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16)); + chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages); + } + + /** + * 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。 + */ + function flushQueuedChatMessages() { + chatMessageFlushTimer = null; + + const batch = pendingChatMessages.splice(0, CHAT_MESSAGE_FLUSH_BATCH_SIZE); + const renderBatch = createChatMessageRenderBatch(); + batch.forEach((msg) => appendMessage(msg, renderBatch)); + commitChatMessageRenderBatch(renderBatch); + + if (pendingChatMessages.length === 0) { + return; + } + + const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16)); + chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages); + } + /** * 将消息追加函数暴露到全局,供页面首次加载时回填历史消息使用。 */ @@ -2662,7 +2853,7 @@ .chatContext.username) { return; } - appendMessage(msg); + enqueueChatMessage(msg); if (msg.action === 'vip_presence') { showVipPresenceBanner(msg); @@ -2814,6 +3005,7 @@ } }); } + lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null; // 显示清屏提示 const sysDiv = document.createElement('div'); @@ -3067,38 +3259,9 @@ window.handleFeatureLocalClear = handleFeatureLocalClear; syncDailyStatusUi(); - // ── 字号设置(持久化到 localStorage)───────────────── - /** - * 应用字号到聊天消息窗口,并保存到 localStorage - * - * @param {string|number} size 字号大小(px 数字) - */ - function applyFontSize(size) { - const px = parseInt(size, 10); - if (isNaN(px) || px < 10 || px > 30) return; - - // 同时应用到公聊窗和包厢窗 - const c1 = document.getElementById('chat-messages-container'); - const c2 = document.getElementById('chat-messages-container2'); - if (c1) c1.style.fontSize = px + 'px'; - if (c2) c2.style.fontSize = px + 'px'; - - // 持久化(key 带房间 ID,不同房间各自记住) - const key = 'chat_font_size'; - localStorage.setItem(key, px); - - // 同步 select 显示 - const sel = document.getElementById('font_size_select'); - if (sel) sel.value = String(px); - } - window.applyFontSize = applyFontSize; - // 页面加载后从 localStorage 恢复之前保存的字号 document.addEventListener('DOMContentLoaded', () => { - const saved = localStorage.getItem('chat_font_size'); - if (saved) { - applyFontSize(saved); - } + window.ChatRoomTools?.restoreChatFontSize?.(); const storedBlockedSystemSenders = loadBlockedSystemSenders(); const mutedFromLocal = localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === '1'; @@ -3290,55 +3453,7 @@ } } - /** - * 打开聊天图片大图预览层。 - */ - function openChatImageLightbox(imageUrl, imageName = '聊天图片') { - const lightbox = document.getElementById('chat-image-lightbox'); - const imageEl = document.getElementById('chat-image-lightbox-img'); - const nameEl = document.getElementById('chat-image-lightbox-name'); - - if (!lightbox || !imageEl || !imageUrl) { - return; - } - - imageEl.src = imageUrl; - imageEl.alt = imageName; - - if (nameEl) { - nameEl.textContent = imageName; - } - - lightbox.style.display = 'block'; - document.body.style.overflow = 'hidden'; - } - - /** - * 关闭聊天图片大图预览层。 - */ - function closeChatImageLightbox(event = null) { - // 如果是点击事件,且点击的目标不是背景或关闭按钮(比如点击了图片本身且没有阻止冒泡),则不关闭 - // 已经在 HTML 中对 img 做了 stopPropagation,此处 event.target !== event.currentTarget 仍是安全的 - if (event && event.target !== event.currentTarget) { - return; - } - - const lightbox = document.getElementById('chat-image-lightbox'); - if (!lightbox) return; - - lightbox.style.display = 'none'; - - const imageEl = document.getElementById('chat-image-lightbox-img'); - if (imageEl) { - imageEl.src = ''; - } - - document.body.style.overflow = ''; - } - window.handleChatImageSelected = handleChatImageSelected; - window.openChatImageLightbox = openChatImageLightbox; - window.closeChatImageLightbox = closeChatImageLightbox; syncChatComposerAfterResume(); if (_contentInput) { @@ -3620,6 +3735,7 @@ // 清理包厢窗口 const say2 = document.getElementById('chat-messages-container2'); if (say2) say2.innerHTML = ''; + lastAutosaveNode = null; // 将当前最大消息 ID 保存至本地,刷新时只显示大于此 ID 的历史记录 localStorage.setItem(`local_clear_msg_id_${window.chatContext.roomId}`, _maxMsgId); @@ -3776,8 +3892,10 @@ detailDiv.innerHTML = `${escapeHtml(levelInfo + gainInfo)}(${timeStr})`; // 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知。 - container2.querySelectorAll('[data-autosave="1"]').forEach(el => el.remove()); + lastAutosaveNode?.remove(); + lastAutosaveNode = detailDiv; container2.appendChild(detailDiv); + pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT); if (autoScroll) container2.scrollTop = container2.scrollHeight; } else { return;