迁移聊天室前端工具并优化消息渲染
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 "./bootstrap";
|
||||||
|
import "./chat-room.js";
|
||||||
|
|
||||||
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
|
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
|
||||||
// 通过 Presence Channel 实现聊天室的核心监听。
|
// 通过 Presence Channel 实现聊天室的核心监听。
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<input id="mob-user-search-input" type="text" placeholder="搜索用户..."
|
<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;"
|
style="flex:2;font-size:11px;border:1px solid #b0c8e0;border-radius:3px;padding:2px 6px;color:#333;"
|
||||||
oninput="renderMobileUserList()">
|
oninput="scheduleRenderMobileUserList()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 用户列表容器 --}}
|
{{-- 用户列表容器 --}}
|
||||||
@@ -127,6 +127,10 @@
|
|||||||
* @type {string|null}
|
* @type {string|null}
|
||||||
*/
|
*/
|
||||||
let _mobileDrawerOpen = 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;
|
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');
|
const container = document.getElementById('mob-rooms-online-list');
|
||||||
if (!container) return;
|
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>';
|
container.innerHTML = '<div style="text-align:center;color:#aaa;padding:16px;font-size:11px;">加载中...</div>';
|
||||||
|
|
||||||
fetch('{{ route('chat.rooms-online-status') }}')
|
fetch('{{ route('chat.rooms-online-status') }}')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.rooms || !data.rooms.length) {
|
_mobileRoomsOnlineStatusCache = data;
|
||||||
container.innerHTML = '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
|
_mobileRoomsOnlineStatusCacheAt = Date.now();
|
||||||
return;
|
renderMobileRoomList(data, container);
|
||||||
}
|
})
|
||||||
const currentRoomId = window.chatContext?.roomId;
|
.catch(() => {
|
||||||
const roomRows = data.rooms.map(room => {
|
container.innerHTML = '<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
|
||||||
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 ?? ''));
|
* @param {Object} data 接口返回数据
|
||||||
const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0);
|
* @param {HTMLElement} container 目标容器
|
||||||
const badge = safeOnlineCount > 0
|
*/
|
||||||
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 6px;font-size:10px;font-weight:bold;">${safeOnlineCount}人</span>`
|
function renderMobileRoomList(data, container) {
|
||||||
: `<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 6px;font-size:10px;">空</span>`;
|
if (window.ChatRoomTools?.renderRoomsOnlineStatusToContainer) {
|
||||||
const currentTag = isCurrent ? `<span style="font-size:9px;color:#7090b0;margin-left:3px;">当前</span>` : '';
|
window.ChatRoomTools.renderRoomsOnlineStatusToContainer(data, container, {
|
||||||
const clickAttr = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
|
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;
|
style="display:flex;align-items:center;justify-content:space-between;
|
||||||
padding:6px 10px;border-bottom:1px solid #eef2f8;background:${bg};
|
padding:6px 10px;border-bottom:1px solid #eef2f8;background:${bg};
|
||||||
cursor:${isCurrent ? 'default' : 'pointer'};">
|
cursor:${isCurrent ? 'default' : 'pointer'};">
|
||||||
@@ -270,13 +319,9 @@
|
|||||||
${safeRoomName}${currentTag}
|
${safeRoomName}${currentTag}
|
||||||
</span>${badge}
|
</span>${badge}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).filter(Boolean).join('');
|
}).filter(Boolean).join('');
|
||||||
|
|
||||||
container.innerHTML = roomRows || '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
|
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>';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; align-items:center; gap:2px;">
|
<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;">
|
style="width:100%; font-size:11px; padding:2px 4px; border:1px solid #aac; border-radius:2px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1420,8 +1420,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 页面刷新后恢复婚礼红包领取按钮 ─────────────────────────
|
// ── 页面刷新后恢复婚礼红包领取按钮 ─────────────────────────
|
||||||
// 延迟 2 秒以确保聊天框和 Alpine 均已完成初始化
|
// 空闲时再查待领取红包,避免和聊天室首屏消息/名单初始化抢网络。
|
||||||
setTimeout(async () => {
|
window.deferChatGameBootstrap(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/wedding/pending-envelopes', {
|
const res = await fetch('/wedding/pending-envelopes', {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1467,6 +1467,6 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[婚礼红包] 恢复待领取按钮失败', e);
|
console.warn('[婚礼红包] 恢复待领取按钮失败', e);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
const toUserSelect = document.getElementById('to_user');
|
const toUserSelect = document.getElementById('to_user');
|
||||||
const onlineCount = document.getElementById('online-count');
|
const onlineCount = document.getElementById('online-count');
|
||||||
const onlineCountBottom = document.getElementById('online-count-bottom');
|
const onlineCountBottom = document.getElementById('online-count-bottom');
|
||||||
const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = 'chat_blocked_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 = 'chat_sound_muted';
|
const CHAT_SOUND_MUTED_STORAGE_KEY = window.ChatRoomTools?.CHAT_SOUND_MUTED_STORAGE_KEY || 'chat_sound_muted';
|
||||||
const BLOCKABLE_SYSTEM_SENDERS = ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子'];
|
const BLOCKABLE_SYSTEM_SENDERS = window.ChatRoomTools?.BLOCKABLE_SYSTEM_SENDERS || ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子'];
|
||||||
const hoverTooltip = document.getElementById('chat-hover-tooltip');
|
const hoverTooltip = document.getElementById('chat-hover-tooltip');
|
||||||
let activeTooltipTrigger = null;
|
let activeTooltipTrigger = null;
|
||||||
|
|
||||||
@@ -75,6 +75,13 @@
|
|||||||
let userBadgeRotationTick = 0;
|
let userBadgeRotationTick = 0;
|
||||||
let userListRenderTimer = null;
|
let userListRenderTimer = null;
|
||||||
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
|
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 || {});
|
const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {});
|
||||||
let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
|
let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
|
||||||
|
|
||||||
@@ -85,6 +92,10 @@
|
|||||||
* @returns {Object}
|
* @returns {Object}
|
||||||
*/
|
*/
|
||||||
function normalizeChatPreferences(raw) {
|
function normalizeChatPreferences(raw) {
|
||||||
|
if (window.ChatRoomTools?.normalizeChatPreferences) {
|
||||||
|
return window.ChatRoomTools.normalizeChatPreferences(raw, BLOCKABLE_SYSTEM_SENDERS);
|
||||||
|
}
|
||||||
|
|
||||||
const blocked = Array.isArray(raw?.blocked_system_senders)
|
const blocked = Array.isArray(raw?.blocked_system_senders)
|
||||||
? raw.blocked_system_senders.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender))
|
? raw.blocked_system_senders.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender))
|
||||||
: [];
|
: [];
|
||||||
@@ -103,6 +114,10 @@
|
|||||||
* @returns {Date|null}
|
* @returns {Date|null}
|
||||||
*/
|
*/
|
||||||
function parseDailyStatusExpiry(expiresAt) {
|
function parseDailyStatusExpiry(expiresAt) {
|
||||||
|
if (window.ChatRoomTools?.parseDailyStatusExpiry) {
|
||||||
|
return window.ChatRoomTools.parseDailyStatusExpiry(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
if (!expiresAt) {
|
if (!expiresAt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -119,6 +134,10 @@
|
|||||||
* @returns {Object|null}
|
* @returns {Object|null}
|
||||||
*/
|
*/
|
||||||
function normalizeDailyStatus(raw) {
|
function normalizeDailyStatus(raw) {
|
||||||
|
if (window.ChatRoomTools?.normalizeDailyStatus) {
|
||||||
|
return window.ChatRoomTools.normalizeDailyStatus(raw);
|
||||||
|
}
|
||||||
|
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!raw || typeof raw !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1459,6 +1478,9 @@
|
|||||||
|
|
||||||
// ── Tab 切换 ──────────────────────────────────────
|
// ── Tab 切换 ──────────────────────────────────────
|
||||||
let _roomsRefreshTimer = null;
|
let _roomsRefreshTimer = null;
|
||||||
|
let _roomsOnlineStatusCache = null;
|
||||||
|
let _roomsOnlineStatusCacheAt = 0;
|
||||||
|
const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
// 切换名单/房间 面板
|
// 切换名单/房间 面板
|
||||||
@@ -1470,7 +1492,7 @@
|
|||||||
if (tab === 'rooms') {
|
if (tab === 'rooms') {
|
||||||
loadRoomsOnlineStatus();
|
loadRoomsOnlineStatus();
|
||||||
clearInterval(_roomsRefreshTimer);
|
clearInterval(_roomsRefreshTimer);
|
||||||
_roomsRefreshTimer = setInterval(loadRoomsOnlineStatus, 30000);
|
_roomsRefreshTimer = setInterval(() => loadRoomsOnlineStatus(true), 30000);
|
||||||
} else {
|
} else {
|
||||||
clearInterval(_roomsRefreshTimer);
|
clearInterval(_roomsRefreshTimer);
|
||||||
_roomsRefreshTimer = null;
|
_roomsRefreshTimer = null;
|
||||||
@@ -1482,42 +1504,72 @@
|
|||||||
*/
|
*/
|
||||||
const _currentRoomId = {{ $room->id }};
|
const _currentRoomId = {{ $room->id }};
|
||||||
|
|
||||||
function loadRoomsOnlineStatus() {
|
function loadRoomsOnlineStatus(forceRefresh = false) {
|
||||||
const container = document.getElementById('rooms-online-list');
|
const container = document.getElementById('rooms-online-list');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && _roomsOnlineStatusCache && Date.now() - _roomsOnlineStatusCacheAt < ROOMS_ONLINE_STATUS_CACHE_TTL) {
|
||||||
|
renderRoomsOnlineStatus(_roomsOnlineStatusCache, container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch('{{ route('chat.rooms-online-status') }}')
|
fetch('{{ route('chat.rooms-online-status') }}')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.rooms || !data.rooms.length) {
|
_roomsOnlineStatusCache = data;
|
||||||
container.innerHTML =
|
_roomsOnlineStatusCacheAt = Date.now();
|
||||||
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
|
renderRoomsOnlineStatus(data, container);
|
||||||
return;
|
})
|
||||||
}
|
.catch(() => {
|
||||||
const roomRows = data.rooms.map(room => {
|
container.innerHTML =
|
||||||
const roomId = Number.parseInt(room.id, 10);
|
'<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
|
||||||
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);
|
* @param {Object} data 接口返回数据
|
||||||
const bg = isCurrent ? '#ecf4ff' : '#fff';
|
* @param {HTMLElement} container 目标容器
|
||||||
const border = isCurrent ? '#aac5f0' : '#e0eaf5';
|
*/
|
||||||
const nameColor = isCurrent ? '#336699' : (closed ? '#bbb' : '#444');
|
function renderRoomsOnlineStatus(data, container) {
|
||||||
const badge = safeOnlineCount > 0 ?
|
if (window.ChatRoomTools?.renderRoomsOnlineStatusToContainer) {
|
||||||
`<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>` :
|
window.ChatRoomTools.renderRoomsOnlineStatusToContainer(data, container, {
|
||||||
`<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 5px;font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
|
currentRoomId: _currentRoomId,
|
||||||
const currentTag = isCurrent ?
|
variant: 'desktop',
|
||||||
`<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>` :
|
});
|
||||||
'';
|
return;
|
||||||
const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
|
}
|
||||||
|
|
||||||
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;
|
style="display:flex;align-items:center;justify-content:space-between;
|
||||||
padding:5px 8px;margin:2px 3px;border-radius:5px;
|
padding:5px 8px;margin:2px 3px;border-radius:5px;
|
||||||
border:1px solid ${border};background:${bg};
|
border:1px solid ${border};background:${bg};
|
||||||
@@ -1530,15 +1582,10 @@
|
|||||||
</span>
|
</span>
|
||||||
${badge}
|
${badge}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).filter(Boolean).join('');
|
}).filter(Boolean).join('');
|
||||||
|
|
||||||
container.innerHTML = roomRows ||
|
container.innerHTML = roomRows ||
|
||||||
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
|
'<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>';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2215,11 +2262,34 @@
|
|||||||
|
|
||||||
// 名单中多种徽标轮换展示时,每 3 秒只刷新徽标槽位,不重建头像行。
|
// 名单中多种徽标轮换展示时,每 3 秒只刷新徽标槽位,不重建头像行。
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
|
if (document.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
userBadgeRotationTick = (userBadgeRotationTick + 1) % 4;
|
userBadgeRotationTick = (userBadgeRotationTick + 1) % 4;
|
||||||
refreshRenderedUserBadges();
|
refreshRenderedUserBadges(userList);
|
||||||
|
const mobileUsersList = document.getElementById('mob-online-users-list');
|
||||||
|
if (mobileUsersList?.offsetParent !== null) {
|
||||||
|
refreshRenderedUserBadges(mobileUsersList);
|
||||||
|
}
|
||||||
syncDailyStatusUi();
|
syncDailyStatusUi();
|
||||||
}, 3000);
|
}, 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,用于本地清屏功能
|
// 记录拉取到的最大消息ID,用于本地清屏功能
|
||||||
if (msg && msg.id > _maxMsgId) {
|
if (msg && msg.id > _maxMsgId) {
|
||||||
_maxMsgId = msg.id;
|
_maxMsgId = msg.id;
|
||||||
@@ -2524,8 +2594,11 @@
|
|||||||
if (msg.welcome_user) {
|
if (msg.welcome_user) {
|
||||||
div.setAttribute('data-system-user', 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());
|
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 (isRelatedToMe) {
|
||||||
// 删除旧的存点通知,保持包厢窗口整洁
|
// 删除旧的存点通知,保持包厢窗口整洁
|
||||||
if (isAutoSave) {
|
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);
|
container2.appendChild(div);
|
||||||
|
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
|
||||||
if (autoScroll) {
|
if (autoScroll) {
|
||||||
container2.scrollTop = container2.scrollHeight;
|
container2.scrollTop = container2.scrollHeight;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (renderBatch) {
|
||||||
|
renderBatch.publicFragment.appendChild(div);
|
||||||
|
renderBatch.shouldPrunePublic = true;
|
||||||
|
renderBatch.shouldScrollPublic = renderBatch.shouldScrollPublic || autoScroll;
|
||||||
|
return;
|
||||||
|
}
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
|
||||||
scrollToBottom();
|
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) {
|
.chatContext.username) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendMessage(msg);
|
enqueueChatMessage(msg);
|
||||||
|
|
||||||
if (msg.action === 'vip_presence') {
|
if (msg.action === 'vip_presence') {
|
||||||
showVipPresenceBanner(msg);
|
showVipPresenceBanner(msg);
|
||||||
@@ -2814,6 +3005,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null;
|
||||||
|
|
||||||
// 显示清屏提示
|
// 显示清屏提示
|
||||||
const sysDiv = document.createElement('div');
|
const sysDiv = document.createElement('div');
|
||||||
@@ -3067,38 +3259,9 @@
|
|||||||
window.handleFeatureLocalClear = handleFeatureLocalClear;
|
window.handleFeatureLocalClear = handleFeatureLocalClear;
|
||||||
syncDailyStatusUi();
|
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 恢复之前保存的字号
|
// 页面加载后从 localStorage 恢复之前保存的字号
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const saved = localStorage.getItem('chat_font_size');
|
window.ChatRoomTools?.restoreChatFontSize?.();
|
||||||
if (saved) {
|
|
||||||
applyFontSize(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedBlockedSystemSenders = loadBlockedSystemSenders();
|
const storedBlockedSystemSenders = loadBlockedSystemSenders();
|
||||||
const mutedFromLocal = localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === '1';
|
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.handleChatImageSelected = handleChatImageSelected;
|
||||||
window.openChatImageLightbox = openChatImageLightbox;
|
|
||||||
window.closeChatImageLightbox = closeChatImageLightbox;
|
|
||||||
syncChatComposerAfterResume();
|
syncChatComposerAfterResume();
|
||||||
|
|
||||||
if (_contentInput) {
|
if (_contentInput) {
|
||||||
@@ -3620,6 +3735,7 @@
|
|||||||
// 清理包厢窗口
|
// 清理包厢窗口
|
||||||
const say2 = document.getElementById('chat-messages-container2');
|
const say2 = document.getElementById('chat-messages-container2');
|
||||||
if (say2) say2.innerHTML = '';
|
if (say2) say2.innerHTML = '';
|
||||||
|
lastAutosaveNode = null;
|
||||||
|
|
||||||
// 将当前最大消息 ID 保存至本地,刷新时只显示大于此 ID 的历史记录
|
// 将当前最大消息 ID 保存至本地,刷新时只显示大于此 ID 的历史记录
|
||||||
localStorage.setItem(`local_clear_msg_id_${window.chatContext.roomId}`, _maxMsgId);
|
localStorage.setItem(`local_clear_msg_id_${window.chatContext.roomId}`, _maxMsgId);
|
||||||
@@ -3776,8 +3892,10 @@
|
|||||||
detailDiv.innerHTML =
|
detailDiv.innerHTML =
|
||||||
`<span style="color: green;">${escapeHtml(levelInfo + gainInfo)}</span><span class="msg-time">(${timeStr})</span>`;
|
`<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);
|
container2.appendChild(detailDiv);
|
||||||
|
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
|
||||||
if (autoScroll) container2.scrollTop = container2.scrollHeight;
|
if (autoScroll) container2.scrollTop = container2.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user