// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { isExpiredChatImageMessage } from "./message-utils.js";
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
import { escapePresenceText } from "./vip-presence.js";
import {
BLOCKABLE_SYSTEM_SENDERS,
PUBLIC_MESSAGE_NODE_LIMIT,
PRIVATE_MESSAGE_NODE_LIMIT,
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
SYSTEM_USERS,
ACTION_TEXT_MAP,
} from "./chat-state.js";
// ── 游戏标签判断 ──
const GAME_LABEL_PREFIXES = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"];
function isGameLabel(name) {
if (GAME_LABEL_PREFIXES.some((p) => name.startsWith(p))) return true;
if (name.includes(" ")) return true;
return false;
}
// ── 构建自然语序的动作串 ──
function buildActionStr(action, fromHtml, toHtml, verb = "说") {
const info = ACTION_TEXT_MAP[action];
if (!info) return `${fromHtml}对${toHtml}${escapeHtml(String(action || ""))}${verb}:`;
if (info.type === "emotion") return `${fromHtml}${info.word}对${toHtml}${verb}:`;
return `${fromHtml}${info.word}${toHtml},${verb}:`;
}
// ── 可点击用户名 ──
function clickableUser(uName, color, extraClass = "") {
const safeName = escapeHtml(uName);
if (uName === "AI小班长") {
return ``;
}
if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) {
return ``;
}
return ``;
}
// ── 解析内容中【用户名】为可点击标记 ──
function parseBracketUsers(content, color = "#000099") {
return content.replace(/【([^】]+)】/g, (_match, uName) => {
return "【" + clickableUser(uName, color) + "】";
});
}
/**
* 构建聊天消息的内容 HTML。
*/
export function buildChatMessageContent(msg, fontColor, textColorClass) {
const rawContent = msg.content || "";
if (msg.message_type === "image" && !isExpiredChatImageMessage(msg)) {
const fullUrl = escapeHtml(msg.image_url || "");
const thumbUrl = escapeHtml(msg.image_thumb_url || "");
const imageName = escapeHtml(msg.image_original_name || "聊天图片");
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `${rawContent}`
: "";
return `
${captionHtml}
`;
}
if (msg.message_type === "expired_image" || isExpiredChatImageMessage(msg)) {
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `${rawContent}`
: "";
return `
🖼️ 图片已过期
${captionHtml}
`;
}
return rawContent;
}
/**
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)。
*
* @param {Object} msg 消息对象
* @param {Object|null} renderBatch 批量渲染上下文
*/
export function appendMessage(msg, renderBatch = null) {
const state = window.chatState;
if (!state) return;
state.trackMaxMsgId(msg.id || 0);
const isMe = msg.from_user === window.chatContext?.username;
const fontColor = msg.font_color || "#000000";
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
const shouldHideByBlock = blockRuleKey ? state.blockedSystemSenders.has(blockRuleKey) : false;
const div = document.createElement("div");
div.className = "msg-line";
if (msg?.from_user) {
div.dataset.fromUser = msg.from_user;
}
if (blockRuleKey) {
div.dataset.blockKey = blockRuleKey;
}
// ── 消息气泡装扮 ──
if (msg.msg_bubble) {
const bubbleStyle = msg.msg_bubble.replace(/^msg_bubble_/, "");
div.classList.add("msg-bubble--" + bubbleStyle);
}
const timeStr = msg.sent_at || "";
let timeStrOverride = false;
let nameClass = "";
if (msg.msg_name_color) {
nameClass = " msg-name--" + msg.msg_name_color.replace(/^msg_name_/, "");
}
let textColorClass = "";
if (msg.msg_text_color) {
textColorClass = " msg-text--" + msg.msg_text_color.replace(/^msg_text_/, "");
}
// 用户头像
const senderInfo = state.onlineUsers[msg.from_user];
const senderHead = (senderInfo && senderInfo.headface) || "1.gif";
let headImgSrc = senderHead.startsWith("storage/") ? "/" + senderHead : `/images/headface/${senderHead}`;
if (msg.from_user.endsWith("播报") || msg.from_user === "星海小博士" || msg.from_user === "系统传音" || msg.from_user === "系统公告") {
headImgSrc = "/images/bugle.png";
}
// ── 头像框装扮 ──
let avatarFrameClass = null;
const avatarFrameRaw = msg.avatar_frame || (senderInfo && senderInfo.avatar_frame);
if (avatarFrameRaw) {
avatarFrameClass = "avatar-frame--" + avatarFrameRaw.replace(/^avatar_frame_/, "");
}
let headImg = "";
if (avatarFrameClass) {
headImg = '' +
'' +
'
' +
'';
} else {
headImg = '
';
}
const messageBodyHtml = buildChatMessageContent(msg, fontColor, textColorClass);
let html = "";
// ── 消息路由 ──
if (msg.action === "system_welcome") {
div.style.cssText = "margin: 3px 0;";
const iconImg = `
`;
const parsedContent = parseBracketUsers(msg.content);
html = `${iconImg} ${parsedContent}`;
} else if (msg.action === "vip_presence") {
const accent = msg.presence_color || "#f59e0b";
div.style.cssText =
`background: linear-gradient(135deg, #ffffff, ${accent}08); border: 2px solid ${accent}44; border-radius: 16px; padding: 12px 16px; margin: 8px 0; box-shadow: 0 4px 15px ${accent}15; position: relative; overflow: hidden;`;
const icon = escapeHtml(msg.presence_icon || "👑");
const levelName = escapeHtml(msg.presence_level_name || "尊贵会员");
const typeLabel = msg.presence_type === "leave"
? "华丽离场"
: (msg.presence_type === "purchase" ? "荣耀开通" : "荣耀入场");
const safeText = escapePresenceText(msg.presence_text || "");
html = `
${icon}
${typeLabel}
${levelName}
(${timeStr})
${safeText}
${icon}
`;
timeStrOverride = true;
} else if (msg.action === "欢迎") {
div.style.cssText =
"background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);";
const parsedContent = parseBracketUsers(msg.content, "#1d4ed8");
html = `💬 ${parsedContent} (${timeStr})
`;
timeStrOverride = true;
} else if (SYSTEM_USERS.includes(msg.from_user)) {
if (msg.from_user === "系统公告") {
div.style.cssText =
"background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);";
const parsedContent = parseBracketUsers(msg.content, "#dc2626");
html = `${parsedContent} (${timeStr})
`;
timeStrOverride = true;
} else if (msg.from_user === "系统传音") {
const content = msg.content || "";
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
const isPlainNotification =
content.includes("【百家乐】") ||
content.includes("【赛马】") ||
content.includes("神秘箱子") ||
content.includes("【双色球") ||
content.includes("【五子棋】") ||
content.includes("【老虎机】") ||
content.includes("购买了");
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `🌟 ${plainAccentContent}`;
} else if (isPlainNotification) {
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}${clickableUser(msg.from_user, fontColor, nameClass)}:${parsedContent}`;
} else {
div.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;";
let sysTranContent = parseBracketUsers(msg.content);
html = `🌟 ${sysTranContent}`;
}
} else if (msg.from_user === "系统" && msg.to_user && msg.to_user !== "大家") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;";
html = `📢 系统:${msg.content}`;
} else {
let giftHtml = "";
if (msg.gift_image) {
giftHtml = `
`;
}
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}${clickableUser(msg.from_user, fontColor, nameClass)}:${parsedContent}${giftHtml}`;
}
} else if (msg.is_secret) {
if (msg.from_user === "系统") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;";
html = `📢 系统:${msg.content}`;
} else {
const fromHtml = clickableUser(msg.from_user, "#cc00cc", nameClass);
const toHtml = clickableUser(msg.to_user, "#cc00cc");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml, "悄悄说") :
`${fromHtml}对${toHtml}悄悄说:`;
html = `${headImg}${verbStr}${messageBodyHtml}`;
}
} else if (msg.to_user && msg.to_user !== "大家") {
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
const toHtml = clickableUser(msg.to_user, "#000099");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml) :
`${fromHtml}对${toHtml}说:`;
html = `${headImg}${verbStr}${messageBodyHtml}`;
} else {
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
const everyoneHtml = clickableUser("大家", "#000099");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, everyoneHtml) :
`${fromHtml}对${everyoneHtml}说:`;
html = `${headImg}${verbStr}${messageBodyHtml}`;
}
if (!timeStrOverride) {
html += ` (${timeStr})`;
}
div.innerHTML = html;
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
if (shouldHideByBlock) {
div.dataset.blockHidden = "1";
div.style.display = "none";
}
// 后端下发的带有 welcome_user 的系统欢迎/离开消息,替换同类旧消息
if (msg.welcome_user) {
const welcomeKind = msg.welcome_kind || "entry_broadcast";
div.setAttribute("data-system-user", msg.welcome_user);
div.setAttribute("data-system-welcome-kind", welcomeKind);
const removeSameWelcome = (root) => {
root?.querySelectorAll("[data-system-user]").forEach((el) => {
if (el.dataset.systemUser === msg.welcome_user && (el.dataset.systemWelcomeKind || "entry_broadcast") === welcomeKind) {
el.remove();
}
});
};
removeSameWelcome(state.container);
removeSameWelcome(renderBatch?.publicFragment);
removeSameWelcome(renderBatch?.privateFragment);
}
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username;
// 存点通知标记
const isAutoSave = (msg.from_user === "系统" || msg.from_user === "") &&
msg.content && (msg.content.includes("自动存点") || msg.content.includes("手动存点"));
if (isAutoSave) {
div.dataset.autosave = "1";
}
if (isRelatedToMe) {
if (isAutoSave) {
state.lastAutosaveNode?.remove();
state.lastAutosaveNode = div;
}
if (renderBatch) {
renderBatch.privateFragment.appendChild(div);
renderBatch.shouldPrunePrivate = true;
renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || state.autoScroll;
return;
}
const container2 = state.container2;
if (container2) {
container2.appendChild(div);
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
if (state.autoScroll) {
container2.scrollTop = container2.scrollHeight;
}
}
} else {
if (renderBatch) {
renderBatch.publicFragment.appendChild(div);
renderBatch.shouldPrunePublic = true;
renderBatch.shouldScrollPublic = renderBatch.shouldScrollPublic || state.autoScroll;
return;
}
const container = state.container;
if (container) {
container.appendChild(div);
pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
if (state.autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
}
}
/**
* 裁剪聊天窗口内过旧的消息节点,防止长时间在线后 DOM 无限膨胀。
*/
export function pruneMessageContainer(targetContainer, maxNodes) {
if (!targetContainer || targetContainer.childElementCount <= maxNodes) {
return;
}
const state = window.chatState;
while (targetContainer.childElementCount > maxNodes) {
const firstNode = targetContainer.firstElementChild;
if (state && firstNode === state.lastAutosaveNode) {
state.lastAutosaveNode = null;
}
firstNode?.remove();
}
}
/**
* 创建聊天消息批量渲染上下文。
*/
export function createChatMessageRenderBatch() {
return {
publicFragment: document.createDocumentFragment(),
privateFragment: document.createDocumentFragment(),
shouldPrunePublic: false,
shouldPrunePrivate: false,
shouldScrollPublic: false,
shouldScrollPrivate: false,
};
}
/**
* 提交批量渲染的聊天消息,并把裁剪和滚动合并到每批一次。
*/
export function commitChatMessageRenderBatch(renderBatch) {
const state = window.chatState;
if (!state) return;
const hasPublicMessages = renderBatch.publicFragment.childNodes.length > 0;
const hasPrivateMessages = renderBatch.privateFragment.childNodes.length > 0;
if (hasPublicMessages) {
const container = state.container;
if (container) container.appendChild(renderBatch.publicFragment);
}
if (hasPrivateMessages) {
const container2 = state.container2;
if (container2) container2.appendChild(renderBatch.privateFragment);
}
if (renderBatch.shouldPrunePublic) {
const container = state.container;
if (container) pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldPrunePrivate) {
const container2 = state.container2;
if (container2) pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldScrollPublic) {
const container = state.container;
if (container) container.scrollTop = container.scrollHeight;
}
if (renderBatch.shouldScrollPrivate) {
const container2 = state.container2;
if (container2) container2.scrollTop = container2.scrollHeight;
}
}
/**
* 将广播消息放入轻量队列,避免连续广播时同步挤占同一帧的 DOM 渲染。
*/
export function enqueueChatMessage(msg) {
const state = window.chatState;
if (!state) return;
state.trackMaxMsgId(msg.id || 0);
state.pendingChatMessages.push(msg);
if (state.chatMessageFlushTimer !== null) {
return;
}
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
}
/**
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
*/
export function flushQueuedChatMessages() {
const state = window.chatState;
if (!state) return;
state.chatMessageFlushTimer = null;
const batch = state.pendingChatMessages.splice(0, CHAT_MESSAGE_FLUSH_BATCH_SIZE);
const renderBatch = createChatMessageRenderBatch();
batch.forEach((msg) => appendMessage(msg, renderBatch));
commitChatMessageRenderBatch(renderBatch);
if (state.pendingChatMessages.length === 0) {
return;
}
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
}
// ── 挂载到 window 供 Blade 脚本及其他模块使用 ──
window.appendMessage = appendMessage;
window.buildChatMessageContent = buildChatMessageContent;
window.pruneMessageContainer = pruneMessageContainer;
window.createChatMessageRenderBatch = createChatMessageRenderBatch;
window.commitChatMessageRenderBatch = commitChatMessageRenderBatch;
window.enqueueChatMessage = enqueueChatMessage;
window.flushQueuedChatMessages = flushQueuedChatMessages;
export { clickableUser, buildActionStr, parseBracketUsers };