Files
chatroom/resources/js/chat-room/message-renderer.js
T

492 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
// 从 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 `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
}
if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) {
return `<span class="msg-user${extraClass}" style="color: ${color};">${safeName}</span>`;
}
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
}
// ── 解析内容中【用户名】为可点击标记 ──
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
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; max-width:220px; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
: "";
return `
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}" data-chat-image-lightbox-open
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
<img src="${thumbUrl}" alt="${imageName}" loading="lazy" decoding="async"
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
</a>
${captionHtml}
</span>
`;
}
if (msg.message_type === "expired_image" || isExpiredChatImageMessage(msg)) {
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
: "";
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
}
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 = '<span class="avatar-frame-wrapper-sm">' +
'<span class="avatar-frame ' + avatarFrameClass + '"></span>' +
'<img src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
'</span>';
} else {
headImg = '<img src="' + headImgSrc + '" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src=\'/images/headface/1.gif\'">';
}
const messageBodyHtml = buildChatMessageContent(msg, fontColor, textColorClass);
let html = "";
// ── 消息路由 ──
if (msg.action === "system_welcome") {
div.style.cssText = "margin: 3px 0;";
const iconImg = `<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
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 = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
</div>
`;
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 userName = msg.from_user;
const rawContent = msg.content || "";
const colonIndex = rawContent.indexOf("");
let clickablePrefix = "";
let bodyPart = rawContent;
if (colonIndex !== -1) {
const prefixStr = rawContent.substring(0, colonIndex);
bodyPart = rawContent.substring(colonIndex);
const lastIdx = prefixStr.lastIndexOf(userName);
if (lastIdx !== -1) {
clickablePrefix =
prefixStr.substring(0, lastIdx) +
clickableUser(userName, "#1d4ed8", nameClass);
} else {
clickablePrefix = prefixStr;
}
}
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
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 = `<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
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 = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (isPlainNotification) {
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`;
} 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 = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
}
} 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 = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
let giftHtml = "";
if (msg.gift_image) {
giftHtml = `<img src="${msg.gift_image}" alt="${msg.gift_name || ""}" style="display:inline-block;width:40px;height:40px;vertical-align:middle;margin-left:6px;animation:giftBounce 0.6s ease-in-out;">`;
}
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}">${parsedContent}</span>${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 = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} 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}<span class="msg-secret">${verbStr}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
}
} 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}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
} 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}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
}
if (!timeStrOverride) {
html += ` <span class="msg-time">(${timeStr})</span>`;
}
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 };