// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。 // 从 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 `${safeName}`; } if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) { return `${safeName}`; } return `${safeName}`; } // ── 解析内容中【用户名】为可点击标记 ── 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 ` ${imageName} ${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 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 = `
💬 ${clickablePrefix}${parsedBody} (${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 = `${msg.gift_name || `; } 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 };