迁移聊天室前端工具并优化消息渲染
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "<br>");
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user