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

This commit is contained in:
2026-04-25 03:34:31 +08:00
parent e3cba255f9
commit f1d8d20180
12 changed files with 786 additions and 154 deletions
+69
View File
@@ -0,0 +1,69 @@
// 聊天室 Vite 入口,集中导出从 Blade 内联脚本迁移出的纯前端工具。
export { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js";
export { applyFontSize, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js";
export { closeChatImageLightbox, openChatImageLightbox } from "./chat-room/lightbox.js";
export {
BLOCKABLE_SYSTEM_SENDERS,
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
CHAT_SOUND_MUTED_STORAGE_KEY,
normalizeChatPreferences,
normalizeDailyStatus,
parseDailyStatusExpiry,
} from "./chat-room/preferences-status.js";
export {
normalizeRoomStatus,
renderRoomStatusRow,
renderRoomsOnlineStatus,
renderRoomsOnlineStatusToContainer,
resolveRoomUrl,
} from "./chat-room/rooms.js";
export { createMessageQueue } from "./chat-room/message-queue.js";
import { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js";
import { applyFontSize, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js";
import { closeChatImageLightbox, openChatImageLightbox } from "./chat-room/lightbox.js";
import {
BLOCKABLE_SYSTEM_SENDERS,
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
CHAT_SOUND_MUTED_STORAGE_KEY,
normalizeChatPreferences,
normalizeDailyStatus,
parseDailyStatusExpiry,
} from "./chat-room/preferences-status.js";
import {
normalizeRoomStatus,
renderRoomStatusRow,
renderRoomsOnlineStatus,
renderRoomsOnlineStatusToContainer,
resolveRoomUrl,
} from "./chat-room/rooms.js";
import { createMessageQueue } from "./chat-room/message-queue.js";
if (typeof window !== "undefined") {
window.ChatRoomTools = {
escapeHtml,
escapeHtmlWithLineBreaks,
applyFontSize,
CHAT_FONT_SIZE_STORAGE_KEY,
restoreChatFontSize,
closeChatImageLightbox,
openChatImageLightbox,
BLOCKABLE_SYSTEM_SENDERS,
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
CHAT_SOUND_MUTED_STORAGE_KEY,
normalizeChatPreferences,
normalizeDailyStatus,
parseDailyStatusExpiry,
normalizeRoomStatus,
renderRoomStatusRow,
renderRoomsOnlineStatus,
renderRoomsOnlineStatusToContainer,
resolveRoomUrl,
createMessageQueue,
};
window.closeChatImageLightbox = closeChatImageLightbox;
window.openChatImageLightbox = openChatImageLightbox;
window.applyFontSize = applyFontSize;
}
+45
View File
@@ -0,0 +1,45 @@
// 聊天室字号偏好控制,保留旧的 localStorage key 以兼容已有用户设置。
export const CHAT_FONT_SIZE_STORAGE_KEY = "chat_font_size";
/**
* 应用字号到聊天消息窗口,并保存到 localStorage。
*
* @param {string|number} size 字号大小
* @returns {boolean}
*/
export function applyFontSize(size) {
const px = Number.parseInt(size, 10);
if (Number.isNaN(px) || px < 10 || px > 30) {
return false;
}
const publicContainer = document.getElementById("chat-messages-container");
const privateContainer = document.getElementById("chat-messages-container2");
if (publicContainer) {
publicContainer.style.fontSize = `${px}px`;
}
if (privateContainer) {
privateContainer.style.fontSize = `${px}px`;
}
localStorage.setItem(CHAT_FONT_SIZE_STORAGE_KEY, String(px));
const selector = document.getElementById("font_size_select");
if (selector) {
selector.value = String(px);
}
return true;
}
/**
* 从 localStorage 恢复已保存的聊天室字号。
*
* @returns {boolean}
*/
export function restoreChatFontSize() {
const saved = localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY);
return saved ? applyFontSize(saved) : false;
}
+29
View File
@@ -0,0 +1,29 @@
// 聊天室前端 HTML 安全工具,供 Blade 内联脚本迁移到 Vite 后复用。
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
/**
* 转义可被拼入 innerHTML 的动态文本。
*
* @param {unknown} value
* @returns {string}
*/
export function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char]);
}
/**
* 转义多行文本,并保留换行展示。
*
* @param {unknown} value
* @returns {string}
*/
export function escapeHtmlWithLineBreaks(value) {
return escapeHtml(value).replace(/\n/g, "<br>");
}
+54
View File
@@ -0,0 +1,54 @@
// 聊天室图片预览层控制,迁出 Blade 内联脚本后仍通过 window 全局函数兼容现有 onclick。
/**
* 打开聊天图片大图预览层。
*
* @param {string} imageUrl 图片地址
* @param {string} imageName 图片名称
* @returns {void}
*/
export function openChatImageLightbox(imageUrl, imageName = "聊天图片") {
const lightbox = document.getElementById("chat-image-lightbox");
const imageEl = document.getElementById("chat-image-lightbox-img");
const nameEl = document.getElementById("chat-image-lightbox-name");
if (!lightbox || !imageEl || !imageUrl) {
return;
}
imageEl.src = imageUrl;
imageEl.alt = imageName;
if (nameEl) {
nameEl.textContent = imageName;
}
lightbox.style.display = "block";
document.body.style.overflow = "hidden";
}
/**
* 关闭聊天图片大图预览层。
*
* @param {Event|null} event 点击事件
* @returns {void}
*/
export function closeChatImageLightbox(event = null) {
if (event && event.target !== event.currentTarget) {
return;
}
const lightbox = document.getElementById("chat-image-lightbox");
if (!lightbox) {
return;
}
lightbox.style.display = "none";
const imageEl = document.getElementById("chat-image-lightbox-img");
if (imageEl) {
imageEl.src = "";
}
document.body.style.overflow = "";
}
+63
View File
@@ -0,0 +1,63 @@
// 聊天室消息队列工具,用于后续迁移接收消息、批量渲染和节流刷新逻辑。
/**
* 创建可控长度的消息队列。
*
* @param {{limit?:number, onFlush?:(messages:unknown[])=>void, scheduler?:(callback:()=>void)=>unknown}} options
* @returns {{enqueue:(message:unknown)=>number, flush:()=>unknown[], scheduleFlush:()=>void, clear:()=>void, size:()=>number, toArray:()=>unknown[]}}
*/
export function createMessageQueue(options = {}) {
const limit = Math.max(Number.parseInt(options.limit, 10) || 200, 1);
const queue = [];
let flushScheduled = false;
const scheduler = typeof options.scheduler === "function"
? options.scheduler
: (callback) => {
const requestFrame = globalThis.requestAnimationFrame || ((handler) => globalThis.setTimeout(handler, 16));
return requestFrame(callback);
};
const flush = () => {
flushScheduled = false;
const messages = queue.splice(0, queue.length);
if (messages.length && typeof options.onFlush === "function") {
options.onFlush(messages);
}
return messages;
};
return {
enqueue(message) {
queue.push(message);
while (queue.length > limit) {
queue.shift();
}
return queue.length;
},
flush,
scheduleFlush() {
if (flushScheduled) {
return;
}
flushScheduled = true;
scheduler(flush);
},
clear() {
queue.splice(0, queue.length);
flushScheduled = false;
},
size() {
return queue.length;
},
toArray() {
return [...queue];
},
};
}
@@ -0,0 +1,75 @@
// 聊天室偏好与每日状态工具,承接从 Blade 内联脚本迁移出的纯数据规整逻辑。
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
/**
* 规整聊天室偏好对象,过滤非法配置并补齐默认值。
*
* @param {Record<string, unknown>|null|undefined} raw
* @param {string[]} blockableSystemSenders
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
*/
export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) {
const blocked = Array.isArray(raw?.blocked_system_senders)
? raw.blocked_system_senders.filter((sender) => blockableSystemSenders.includes(sender))
: [];
return {
blocked_system_senders: Array.from(new Set(blocked)),
sound_muted: Boolean(raw?.sound_muted),
};
}
/**
* 解析并标准化状态到期时间。
*
* @param {string|null|undefined} expiresAt
* @returns {Date|null}
*/
export function parseDailyStatusExpiry(expiresAt) {
if (!expiresAt) {
return null;
}
const parsed = new Date(expiresAt);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* 将状态对象规整为前端统一结构,并过滤掉已过期状态。
*
* @param {Record<string, unknown>|null|undefined} raw
* @param {number} nowTimestamp
* @returns {{key:string,label:string,icon:string,group:string,expires_at:string}|null}
*/
export function normalizeDailyStatus(raw, nowTimestamp = Date.now()) {
if (!raw || typeof raw !== "object") {
return null;
}
const key = String(raw.key ?? raw.daily_status_key ?? "");
const label = String(raw.label ?? raw.daily_status_label ?? "");
const icon = String(raw.icon ?? raw.daily_status_icon ?? "");
const group = String(raw.group ?? raw.daily_status_group ?? "");
const expiresAt = raw.expires_at ?? raw.daily_status_expires_at ?? null;
const parsedExpiry = parseDailyStatusExpiry(expiresAt);
if (!key || !label || !icon || !parsedExpiry) {
return null;
}
if (parsedExpiry.getTime() <= nowTimestamp) {
return null;
}
return {
key,
label,
icon,
group,
expires_at: parsedExpiry.toISOString(),
};
}
+133
View File
@@ -0,0 +1,133 @@
// 聊天室房间在线状态渲染工具,抽离右侧主面板与手机抽屉可共用的纯前端逻辑。
import { escapeHtml } from "./html.js";
const EMPTY_ROOMS_HTML =
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
/**
* 转换接口房间数据,过滤异常房间编号。
*
* @param {Record<string, unknown>} room
* @returns {{id:number,name:string,online:number,doorOpen:boolean}|null}
*/
export function normalizeRoomStatus(room) {
const roomId = Number.parseInt(room?.id, 10);
if (!Number.isInteger(roomId)) {
return null;
}
return {
id: roomId,
name: String(room?.name ?? ""),
online: Math.max(Number.parseInt(room?.online, 10) || 0, 0),
doorOpen: Boolean(room?.door_open),
};
}
/**
* 生成房间跳转地址,默认保持现有 `/room/{id}` 路径。
*
* @param {number} roomId
* @param {(roomId:number) => string} [roomUrlResolver]
* @returns {string}
*/
export function resolveRoomUrl(roomId, roomUrlResolver = undefined) {
return typeof roomUrlResolver === "function" ? String(roomUrlResolver(roomId)) : `/room/${roomId}`;
}
/**
* 生成可放入 onclick 属性的安全跳转语句。
*
* @param {number} roomId
* @param {(roomId:number) => string} [roomUrlResolver]
* @returns {string}
*/
function buildRoomClickHandler(roomId, roomUrlResolver = undefined) {
const safeUrlLiteral = escapeHtml(JSON.stringify(resolveRoomUrl(roomId, roomUrlResolver)));
return `onclick="location.href=${safeUrlLiteral}"`;
}
/**
* 渲染单个房间在线状态行。
*
* @param {{id:number,name:string,online:number,doorOpen:boolean}} room
* @param {{currentRoomId?:number|null, variant?:'desktop'|'mobile', roomUrlResolver?:(roomId:number)=>string}} options
* @returns {string}
*/
export function renderRoomStatusRow(room, options = {}) {
const currentRoomId = Number.parseInt(options.currentRoomId, 10);
const isCurrent = Number.isInteger(currentRoomId) && room.id === currentRoomId;
const variant = options.variant === "mobile" ? "mobile" : "desktop";
const safeRoomName = escapeHtml(room.name);
const bg = isCurrent ? "#ecf4ff" : "#fff";
const nameColor = isCurrent ? "#336699" : (room.doorOpen ? "#444" : "#bbb");
const currentTag = isCurrent
? (variant === "mobile"
? '<span style="font-size:9px;color:#7090b0;margin-left:3px;">当前</span>'
: '<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>')
: "";
const clickHandler = isCurrent ? "" : buildRoomClickHandler(room.id, options.roomUrlResolver);
const badge = room.online > 0
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 ${variant === "mobile" ? "6px" : "5px"};font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${room.online}${variant === "mobile" ? "" : " "}人</span>`
: `<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 ${variant === "mobile" ? "6px" : "5px"};font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
if (variant === "mobile") {
return `<div ${clickHandler}
style="display:flex;align-items:center;justify-content:space-between;
padding:6px 10px;border-bottom:1px solid #eef2f8;background:${bg};
cursor:${isCurrent ? "default" : "pointer"};">
<span style="color:${nameColor};font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:6px;">
${safeRoomName}${currentTag}
</span>${badge}
</div>`;
}
const border = isCurrent ? "#aac5f0" : "#e0eaf5";
return `<div ${clickHandler}
style="display:flex;align-items:center;justify-content:space-between;
padding:5px 8px;margin:2px 3px;border-radius:5px;
border:1px solid ${border};background:${bg};
cursor:${isCurrent ? "default" : "pointer"};
transition:background .15s;"
onmouseover="if(${!isCurrent}) this.style.background='#ddeeff';"
onmouseout="this.style.background='${bg}';">
<span style="color:${nameColor};font-size:11px;font-weight:${isCurrent ? "bold" : "normal"};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:4px;">
${safeRoomName}${currentTag}
</span>
${badge}
</div>`;
}
/**
* 渲染房间在线状态列表 HTML。
*
* @param {{rooms?: Array<Record<string, unknown>>}} data
* @param {{currentRoomId?:number|null, variant?:'desktop'|'mobile', emptyHtml?:string, roomUrlResolver?:(roomId:number)=>string}} options
* @returns {string}
*/
export function renderRoomsOnlineStatus(data, options = {}) {
const rooms = Array.isArray(data?.rooms) ? data.rooms : [];
const rows = rooms
.map((room) => normalizeRoomStatus(room))
.filter(Boolean)
.map((room) => renderRoomStatusRow(room, options))
.join("");
return rows || options.emptyHtml || EMPTY_ROOMS_HTML;
}
/**
* 将房间在线状态列表渲染到指定容器。
*
* @param {{rooms?: Array<Record<string, unknown>>}} data
* @param {HTMLElement} container
* @param {{currentRoomId?:number|null, variant?:'desktop'|'mobile', emptyHtml?:string, roomUrlResolver?:(roomId:number)=>string}} options
* @returns {void}
*/
export function renderRoomsOnlineStatusToContainer(data, container, options = {}) {
container.innerHTML = renderRoomsOnlineStatus(data, options);
}
+1
View File
@@ -1,4 +1,5 @@
import "./bootstrap";
import "./chat-room.js";
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
// 通过 Presence Channel 实现聊天室的核心监听。