迁移聊天室前端工具并优化消息渲染

This commit is contained in:
2026-04-25 03:34:31 +08:00
parent e3cba255f9
commit f1d8d20180
12 changed files with 786 additions and 154 deletions
+69
View File
@@ -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;
}
+45
View File
@@ -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;
}
+29
View File
@@ -0,0 +1,29 @@
// 聊天室前端 HTML 安全工具,供 Blade 内联脚本迁移到 Vite 后复用。
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
/**
* 转义可被拼入 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, "<br>");
}
+54
View File
@@ -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 = "";
}
+63
View File
@@ -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];
},
};
}
@@ -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<string, unknown>|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<string, unknown>|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(),
};
}
+133
View File
@@ -0,0 +1,133 @@
// 聊天室房间在线状态渲染工具,抽离右侧主面板与手机抽屉可共用的纯前端逻辑。
import { escapeHtml } from "./html.js";
const EMPTY_ROOMS_HTML =
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
/**
* 转换接口房间数据,过滤异常房间编号。
*
* @param {Record<string, unknown>} 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"
? '<span style="font-size:9px;color:#7090b0;margin-left:3px;">当前</span>'
: '<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>')
: "";
const clickHandler = isCurrent ? "" : buildRoomClickHandler(room.id, options.roomUrlResolver);
const badge = room.online > 0
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 ${variant === "mobile" ? "6px" : "5px"};font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${room.online}${variant === "mobile" ? "" : " "}人</span>`
: `<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 ${variant === "mobile" ? "6px" : "5px"};font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
if (variant === "mobile") {
return `<div ${clickHandler}
style="display:flex;align-items:center;justify-content:space-between;
padding:6px 10px;border-bottom:1px solid #eef2f8;background:${bg};
cursor:${isCurrent ? "default" : "pointer"};">
<span style="color:${nameColor};font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:6px;">
${safeRoomName}${currentTag}
</span>${badge}
</div>`;
}
const border = isCurrent ? "#aac5f0" : "#e0eaf5";
return `<div ${clickHandler}
style="display:flex;align-items:center;justify-content:space-between;
padding:5px 8px;margin:2px 3px;border-radius:5px;
border:1px solid ${border};background:${bg};
cursor:${isCurrent ? "default" : "pointer"};
transition:background .15s;"
onmouseover="if(${!isCurrent}) this.style.background='#ddeeff';"
onmouseout="this.style.background='${bg}';">
<span style="color:${nameColor};font-size:11px;font-weight:${isCurrent ? "bold" : "normal"};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:4px;">
${safeRoomName}${currentTag}
</span>
${badge}
</div>`;
}
/**
* 渲染房间在线状态列表 HTML。
*
* @param {{rooms?: Array<Record<string, unknown>>}} 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<Record<string, unknown>>}} 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);
}
+1
View File
@@ -1,4 +1,5 @@
import "./bootstrap";
import "./chat-room.js";
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
// 通过 Presence Channel 实现聊天室的核心监听。
@@ -83,7 +83,7 @@
</select>
<input id="mob-user-search-input" type="text" placeholder="搜索用户..."
style="flex:2;font-size:11px;border:1px solid #b0c8e0;border-radius:3px;padding:2px 6px;color:#333;"
oninput="renderMobileUserList()">
oninput="scheduleRenderMobileUserList()">
</div>
{{-- 用户列表容器 --}}
@@ -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 = '<div style="text-align:center;color:#aaa;padding:16px;font-size:11px;">加载中...</div>';
fetch('{{ route('chat.rooms-online-status') }}')
.then(r => r.json())
.then(data => {
if (!data.rooms || !data.rooms.length) {
container.innerHTML = '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
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 = '<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
});
}
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
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 6px;font-size:10px;font-weight:bold;">${safeOnlineCount}人</span>`
: `<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 6px;font-size:10px;">空</span>`;
const currentTag = isCurrent ? `<span style="font-size:9px;color:#7090b0;margin-left:3px;">当前</span>` : '';
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: '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>',
});
return;
}
return `<div ${clickAttr}
if (!data.rooms || !data.rooms.length) {
container.innerHTML = '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
return;
}
const currentRoomId = window.chatContext?.roomId;
const roomRows = data.rooms.map(room => {
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
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 6px;font-size:10px;font-weight:bold;">${safeOnlineCount}人</span>`
: `<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 6px;font-size:10px;">空</span>`;
const currentTag = isCurrent ? `<span style="font-size:9px;color:#7090b0;margin-left:3px;">当前</span>` : '';
const clickAttr = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
return `<div ${clickAttr}
style="display:flex;align-items:center;justify-content:space-between;
padding:6px 10px;border-bottom:1px solid #eef2f8;background:${bg};
cursor:${isCurrent ? 'default' : 'pointer'};">
@@ -270,13 +319,9 @@
${safeRoomName}${currentTag}
</span>${badge}
</div>`;
}).filter(Boolean).join('');
}).filter(Boolean).join('');
container.innerHTML = roomRows || '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
})
.catch(() => {
container.innerHTML = '<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
});
container.innerHTML = roomRows || '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
}
/**
@@ -37,7 +37,7 @@
</select>
</div>
<div style="display:flex; align-items:center; gap:2px;">
<input type="text" id="user-search-input" placeholder="搜索用户" onkeyup="filterUserList()"
<input type="text" id="user-search-input" placeholder="搜索用户" oninput="scheduleFilterUserList()"
style="width:100%; font-size:11px; padding:2px 4px; border:1px solid #aac; border-radius:2px; box-sizing:border-box;">
</div>
</div>
@@ -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);
});
</script>
+240 -122
View File
@@ -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 =
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
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 =
'<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
});
}
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 ?
`<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 5px;font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${safeOnlineCount} 人</span>` :
`<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 5px;font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
const currentTag = isCurrent ?
`<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>` :
'';
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 `<div ${clickHandler}
if (!data.rooms || !data.rooms.length) {
container.innerHTML =
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
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 ?
`<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 5px;font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${safeOnlineCount} 人</span>` :
`<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 5px;font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
const currentTag = isCurrent ?
`<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>` :
'';
const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
return `<div ${clickHandler}
style="display:flex;align-items:center;justify-content:space-between;
padding:5px 8px;margin:2px 3px;border-radius:5px;
border:1px solid ${border};background:${bg};
@@ -1530,15 +1582,10 @@
</span>
${badge}
</div>`;
}).filter(Boolean).join('');
}).filter(Boolean).join('');
container.innerHTML = roomRows ||
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
})
.catch(() => {
container.innerHTML =
'<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
});
container.innerHTML = roomRows ||
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
}
@@ -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 =
`<span style="color: green;">${escapeHtml(levelInfo + gainInfo)}</span><span class="msg-time">(${timeStr})</span>`;
// 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知。
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;