// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。 // 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。 import { escapeHtml, normalizeSafeChatUrl } from "./html.js"; import { attachIdiomAnswerButton, buildQuizActivityTitle, disableIdiomAnswerButtons, isQuizStartMessage, normalizeQuizRoundPayload, } from "./riddle-quiz.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 = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"]; const CHAT_NOTICE_CHIP_FONT_SIZE = "0.82em"; const CHAT_NOTICE_META_FONT_SIZE = "0.72em"; const CHAT_NOTICE_BUTTON_FONT_SIZE = "0.82em"; const CHAT_NOTICE_BODY_FONT_SIZE = "1em"; const CHAT_NOTICE_ICON_FONT_SIZE = "1.08em"; const CHAT_NOTICE_LARGE_ICON_FONT_SIZE = "1.35em"; const CHAT_NOTICE_DECOR_ICON_FONT_SIZE = "4.25em"; 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) + "】"; }); } /** * 构建统一的猜谜活动标题与题型标签。 */ function buildGameLabelChipHtml(label, accentColor) { return `${escapeHtml(label)}`; } /** * 判断当前是否为礼包发放公告。 */ function isRedPacketAnnouncementMessage(msg) { const content = String(msg?.content || ""); return String(msg?.from_user || "") === "系统公告" && content.includes("发出了一个") && content.includes("礼包") && content.includes("立即抢包"); } /** * 构建礼包发放公告的紧凑卡片,整体比例对齐猜谜活动。 */ function buildRedPacketAnnouncementHtml(msg, timeStr) { const rawContent = String(msg?.content || ""); const isExpPacket = rawContent.includes("经验的礼包"); const colorPalette = isExpPacket ? { accent: "#16a34a", text: "#166534", softBackground: "linear-gradient(135deg,#f0fdf4,#f7fee7)", softBorder: "rgba(22,163,74,.18)", chipBackground: "#dcfce7", chipBorder: "#86efac", chipText: "#15803d", } : { accent: "#dc2626", text: "#b91c1c", softBackground: "linear-gradient(135deg,#fef2f2,#fff7ed)", softBorder: "rgba(220,38,38,.18)", chipBackground: "#fee2e2", chipBorder: "#fca5a5", chipText: "#dc2626", }; const accentColor = colorPalette.accent; const typeLabel = isExpPacket ? "经验礼包" : "金币礼包"; const icon = isExpPacket ? "✨" : "🧧"; const buttonMatch = rawContent.match(/]*)>([\s\S]*?)<\/button>/iu); const buttonLabel = String(buttonMatch?.[2] || "立即抢包").trim(); const onclickMatch = String(buttonMatch?.[1] || "").match(/\bonclick=(["'])([\s\S]*?)\1/iu); const buttonOnclick = onclickMatch ? onclickMatch[2] : ""; const textOnlyContent = rawContent .replace(//giu, "") .replace(/<\/?b>/giu, "") .replace(/^🧧\s*/u, "") .trim(); const summary = escapeHtml(textOnlyContent); const actionButtonHtml = `${escapeHtml(buttonLabel)}`; return ` ${icon} ${buildGameLabelChipHtml("礼包红包", accentColor)} ${escapeHtml(typeLabel)} ${summary} (${timeStr}) ${actionButtonHtml} `; } /** * 构建统一的猜谜活动标题与题型标签。 */ function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") { const { activityLabel, typeLabel } = buildQuizActivityTitle(msg); return ` ${buildGameLabelChipHtml(activityLabel, accentColor)} ${escapeHtml(typeLabel)} `; } /** * 判断当前公屏消息是否属于“我自己”的钓鱼结果广播。 * * 说明: * - 收竿后,钓鱼者本人已经会在包厢窗口收到本地结果提示; * - 这里需要把同一条公屏广播对本人隐藏,避免自己同时看到两条。 */ function isOwnFishingResultBroadcast(msg) { const currentUsername = String(window.chatContext?.username || "").trim(); const fishingUsername = String(msg?.fishing_username || "").trim(); if (!currentUsername) { return false; } return String(msg?.from_user || "") === "钓鱼播报" && String(msg?.action || "") === "fishing_result" && fishingUsername === currentUsername; } /** * 判断当前消息是否应该使用统一的游戏通知卡片。 */ function resolveGameNotificationCardMeta(msg) { const normalizedContent = String(msg?.content || ""); const fromUser = String(msg?.from_user || ""); if ( normalizedContent.includes("【百家乐】") || (normalizedContent.includes("开局:") && normalizedContent.includes("点收割")) || (normalizedContent.startsWith("🎲") && normalizedContent.includes("点")) || (normalizedContent.includes("快速参与") && normalizedContent.includes("1:24")) ) { return { label: "百家乐", icon: "🎲", accent: "#2563eb", background: "linear-gradient(135deg,#eff6ff,#f8fbff)", border: "rgba(37,99,235,.16)", text: "#1e3a8a", chipBg: "#dbeafe", }; } if ( normalizedContent.includes("【赛马】") || normalizedContent.startsWith("🐎 开赛:") || normalizedContent.startsWith("🏇 比赛开始:") || normalizedContent.startsWith("🏆 冠军:") || /^🐎\s*第\s*#?\d+\s*场开赛/u.test(normalizedContent) || /^🏇\s*第\s*#?\d+\s*场比赛开始/u.test(normalizedContent) || /^🏆\s*第\s*#?\d+\s*场结束/u.test(normalizedContent) ) { return { label: "赛马", icon: "🏇", accent: "#0f766e", background: "linear-gradient(135deg,#ecfeff,#f0fdfa)", border: "rgba(15,118,110,.16)", text: "#115e59", chipBg: "#ccfbf1", }; } if ( normalizedContent.includes("神秘箱子") || (normalizedContent.includes("暗号") && normalizedContent.includes("《")) || normalizedContent.includes("抢到") || normalizedContent.includes("箱子消失") ) { return { label: "神秘箱子", icon: "📦", accent: "#7c3aed", background: "linear-gradient(135deg,#faf5ff,#fdf4ff)", border: "rgba(124,58,237,.16)", text: "#6b21a8", chipBg: "#ede9fe", }; } if ( normalizedContent.includes("双色球") || /购买\s+\d+\s*期/u.test(normalizedContent) || /\d+\s*期:\s*🔴/u.test(normalizedContent) || normalizedContent.includes("超级期") ) { return { label: "双色球彩票", icon: "🎟️", accent: "#dc2626", background: "linear-gradient(135deg,#fef2f2,#fff7ed)", border: "rgba(220,38,38,.16)", text: "#991b1b", chipBg: "#fee2e2", }; } if (normalizedContent.includes("【五子棋】")) { return { label: "五子棋", icon: "♟️", accent: "#475569", background: "linear-gradient(135deg,#f8fafc,#f1f5f9)", border: "rgba(71,85,105,.16)", text: "#334155", chipBg: "#e2e8f0", }; } if (normalizedContent.includes("老虎机")) { return { label: "老虎机", icon: "🎰", accent: "#d97706", background: "linear-gradient(135deg,#fff7ed,#fffbeb)", border: "rgba(217,119,6,.16)", text: "#9a3412", chipBg: "#fed7aa", }; } if (fromUser === "钓鱼播报") { return { label: "钓鱼", icon: "🎣", accent: "#059669", background: "linear-gradient(135deg,#ecfdf5,#f0fdf4)", border: "rgba(5,150,105,.16)", text: "#065f46", chipBg: "#a7f3d0", }; } return null; } /** * 提炼系统传音卡片正文,去掉和标签重复的前缀。 */ function extractSystemGameCardSummary(content, meta) { const normalizedContent = String(content || "").trim(); if (!meta) { return normalizedContent; } if (meta.label === "神秘箱子") { return normalizedContent .replace(/^[📦💎☠️]\s*/u, "") .replace(/^【神秘箱子】/u, "") .replace(/^开箱播报[::]\s*/u, "") .trim(); } if (meta.label === "百家乐") { if (normalizedContent.includes("开局:")) { return normalizedContent .replace(/^[🎲]+\s*/u, "") .replace(/【百家乐】/u, "") .replace(/\s+/gu, " ") .trim(); } if (/第\s*#?\d+\s*局开奖/u.test(normalizedContent)) { return normalizedContent .replace(/^[🎲🎉]+\s*/u, "") .replace(/^【百家乐】/u, "") .replace(/\s+/gu, " ") .trim(); } return normalizedContent .replace(/^[🎲🎉]+\s*/u, "") .replace(/^【百家乐】/u, "") .replace(/\s+/gu, " ") .trim(); } if (meta.label === "赛马") { return normalizedContent .replace(/^[🐎🏇🏆]+\s*/u, "") .replace(/^【赛马】/u, "") .trim(); } if (meta.label === "双色球彩票") { return normalizedContent .replace(/^[🎟️🎊]+\s*/u, "") .replace(/^【双色球[^】]*】/u, "") .trim(); } if (meta.label === "五子棋") { return normalizedContent .replace(/^[♟️🏆]+\s*/u, "") .replace(/^【五子棋】/u, "") .replace(/^玩家对战结果!/u, "对战结果:") .replace(/^棋神降临!/u, "人机获胜:") .replace(/^AI 大获全胜!/u, "AI获胜:") .trim(); } if (meta.label === "老虎机") { return normalizedContent .replace(/^[🎰🎉]+\s*/u, "") .replace(/^【老虎机大奖】/u, "大奖:") .trim(); } return normalizedContent; } /** * 统一系统游戏卡片中的内嵌按钮样式,避免不同游戏沿用旧尺寸。 */ function normalizeSystemGameCardActions(content, meta) { const normalizedContent = String(content || ""); return normalizedContent.replace(/]*)>([\s\S]*?)<\/button>/giu, (_match, attributes, label) => { const onclickMatch = String(attributes || "").match(/\bonclick=(["'])([\s\S]*?)\1/iu); const onclickAttr = onclickMatch ? ` onclick="${escapeHtml(onclickMatch[2])}"` : ""; const safeLabel = String(label || "").trim(); return `${safeLabel}`; }); } /** * 将系统传音中的游戏通知渲染为和猜谜活动同级的紧凑卡片。 */ function buildSystemGameNotificationHtml(msg, timeStr) { const content = String(msg.content || ""); const meta = resolveGameNotificationCardMeta(msg); if (!meta) { return ""; } const summary = normalizeSystemGameCardActions(extractSystemGameCardSummary(content, meta), meta); return ` ${meta.icon} ${buildGameLabelChipHtml(meta.label, meta.accent)} ${parseBracketUsers(summary, meta.text)} (${timeStr}) `; } /** * 猜谜活动开题消息统一渲染为卡片。 */ function buildQuizStartHtml(msg, timeStr) { const quizMeta = normalizeQuizRoundPayload(msg); const rawHint = String(quizMeta.hint || msg.content || "") .replace(/^🧩\s*/, "") .replace(/^📣\s*/, "") .replace(/^【[^】]+】\s*第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "") .replace(/^【[^】]+】\s*/u, "") .replace(/^第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "") .replace(/^题面:\s*/u, "") .trim(); const safeHint = escapeHtml(rawHint); return ` 🧩 ${buildQuizBadgeHtml(msg)} ${safeHint} (${timeStr}) 💰 ${quizMeta.rewardGold} 金币 ⭐ ${quizMeta.rewardExp} 经验 `; } /** * 提取猜谜活动结束消息里的正确答案,兼容中奖与超时文案。 */ function resolveQuizResultAnswerText(msg) { const quizMeta = normalizeQuizRoundPayload(msg); const explicitAnswer = String(quizMeta.answer || "").trim(); if (explicitAnswer) { return explicitAnswer; } const content = String(msg.content || ""); const matchedAnswer = content.match(/正确答案:(.+?)(?:!|。|$)/u); return matchedAnswer?.[1]?.trim() || content.trim(); } /** * 猜谜活动结束消息统一渲染为和开题通知同级的紧凑卡片。 */ function buildQuizResultHtml(msg, timeStr) { const quizMeta = normalizeQuizRoundPayload(msg); const winnerUsername = String(msg.winner_username || "").trim(); const answerText = escapeHtml(resolveQuizResultAnswerText(msg)); const isAnsweredResult = winnerUsername !== ""; const accentColor = isAnsweredResult ? "#7c3aed" : "#d97706"; const accentBackground = isAnsweredResult ? "linear-gradient(135deg,#f5f3ff,#faf5ff)" : "linear-gradient(135deg,#fff7ed,#fffbeb)"; const accentBorder = isAnsweredResult ? "rgba(124,58,237,.16)" : "rgba(217,119,6,.18)"; const textColor = isAnsweredResult ? "#312e81" : "#9a3412"; const icon = isAnsweredResult ? "🎉" : "⏳"; const iconBackground = isAnsweredResult ? "linear-gradient(135deg,#7c3aed,#a78bfa)" : "linear-gradient(135deg,#f59e0b,#f97316)"; const badgeColor = isAnsweredResult ? "#7c3aed" : "#d97706"; const summaryHtml = isAnsweredResult ? `【${clickableUser(winnerUsername, "#6d28d9")}】率先答对「${answerText}」` : `第 #${quizMeta.endedRoundId || quizMeta.roundId || 0} 题已超时结束,正确答案:${answerText}`; return ` ${icon} ${buildQuizBadgeHtml(msg, badgeColor)} ${summaryHtml} (${timeStr}) ${isAnsweredResult ? ` 💰 ${quizMeta.rewardGold} 金币 ⭐ ${quizMeta.rewardExp} 经验 ` : ""} `; } /** * 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。 */ function prunePrivateIdiomResultMessages(targetContainer, maxRecords = 3) { if (!targetContainer) { return; } const nodes = Array.from(targetContainer.querySelectorAll('[data-idiom-result="1"]')); while (nodes.length > maxRecords) { const firstNode = nodes.shift(); firstNode?.remove(); } } /** * 构建聊天消息的内容 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); if (isOwnFishingResultBroadcast(msg)) { return null; } const quizMeta = normalizeQuizRoundPayload(msg); const idiomRoundId = quizMeta.roundId; const isIdiomStartMessage = isQuizStartMessage(msg) && ["星海小博士", "系统传音"].includes(String(msg.from_user || "")); if (isIdiomStartMessage) { const existingIdiomNode = document.querySelector(`[data-idiom-round-id="${idiomRoundId}"]`); if (existingIdiomNode) { attachIdiomAnswerButton(existingIdiomNode, msg); return existingIdiomNode; } } const isMe = msg.from_user === window.chatContext?.username; // 系统播报屏蔽只作用于公屏窗口;自己相关消息仍要进入包厢窗口,避免屏蔽误伤个人提示。 const isIdiomWinnerHistory = msg.action === "idiom_result" && msg.winner_username === window.chatContext?.username; const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username || isIdiomWinnerHistory; const fontColor = msg.font_color || "#000000"; const blockRuleKey = resolveBlockedSystemSenderKey(msg); const shouldApplyBlockRule = Boolean(blockRuleKey && !isRelatedToMe); const shouldHideByBlock = shouldApplyBlockRule ? 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 (idiomRoundId > 0) { div.dataset.idiomRoundId = String(idiomRoundId); div.dataset.quizRoundId = String(idiomRoundId); } if (shouldApplyBlockRule) { 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 === "idiom_result") { div.dataset.idiomResult = "1"; div.dataset.quizRoundEndedId = String(quizMeta.endedRoundId || quizMeta.roundId || 0); div.dataset.quizWinnerUsername = String(msg.winner_username || ""); html = buildQuizResultHtml(msg, timeStr); timeStrOverride = true; } else if (isIdiomStartMessage) { html = buildQuizStartHtml(msg, timeStr); timeStrOverride = true; } 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 === "系统公告") { if (isRedPacketAnnouncementMessage(msg)) { html = buildRedPacketAnnouncementHtml(msg, timeStr); timeStrOverride = true; } else { 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 isQuizEndNotification = content.includes("猜谜活动") && (content.includes("已超时结束") || content.includes("正确答案")); const isQuizStartNotification = !isQuizEndNotification && (isIdiomStartMessage || content.includes("猜谜活动") || content.includes("猜成语时间")); const systemGameCardMeta = resolveGameNotificationCardMeta(msg); const isPlainNotification = content.includes("购买了"); if (isQuizEndNotification) { html = buildQuizResultHtml(msg, timeStr); timeStrOverride = true; } else if (isQuizStartNotification) { div.style.cssText = "background:linear-gradient(135deg,#fff7ed,#fffbeb);border:1px solid rgba(245,158,11,.28);border-left:4px solid #f59e0b;border-radius:12px;padding:8px 12px;margin:4px 0;box-shadow:0 10px 24px rgba(245,158,11,.14);"; html = ` 📣 ${buildQuizBadgeHtml(msg, "#d97706")} (${timeStr}) ${parseBracketUsers(content, "#b45309")} `; timeStrOverride = true; } else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) { let plainAccentContent = parseBracketUsers(msg.content); html = `🌟 ${plainAccentContent}`; } else if (systemGameCardMeta) { html = buildSystemGameNotificationHtml(msg, timeStr); timeStrOverride = true; } 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 (resolveGameNotificationCardMeta(msg)) { html = buildSystemGameNotificationHtml(msg, timeStr); timeStrOverride = true; } 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;"; 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; attachIdiomAnswerButton(div, msg); // 历史消息恢复或实时结算时,都立即把对应回合按钮置为结束态,保留消息结构便于回看。 if (quizMeta.endedRoundId > 0) { disableIdiomAnswerButtons(quizMeta.endedRoundId, "本回合已结束", String(msg.winner_username || "")); } // 命中屏蔽规则时,消息仍保留在 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); } // 存点通知标记 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; if (msg.action === "idiom_result") { renderBatch.shouldPrunePrivateIdiomResults = true; } return; } const container2 = state.container2; if (container2) { container2.appendChild(div); pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT); if (msg.action === "idiom_result") { prunePrivateIdiomResultMessages(container2, 3); } 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, shouldPrunePrivateIdiomResults: 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.shouldPrunePrivateIdiomResults) { const container2 = state.container2; if (container2) prunePrivateIdiomResultMessages(container2, 3); } 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); } /** * 判断是否为普通用户聊天消息(非系统/游戏通知)。 */ function isUserChatMessage(msg) { if (!msg || !msg.from_user) return false; const u = msg.from_user; if (SYSTEM_USERS.includes(u)) return false; if (u.endsWith("播报")) return false; if (u === "百家乐" || u === "跑马") return false; return true; } /** 后台恢复时系统通知最多保留条数 */ const MAX_SYSTEM_BURST = 20; /** 后台恢复时超过该时间的系统通知直接丢弃(分钟) */ const MAX_SYSTEM_AGE_MINUTES = 10; /** * 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。 */ export function flushQueuedChatMessages() { const state = window.chatState; if (!state) return; state.chatMessageFlushTimer = null; // 大批量消息堆积(后台标签页恢复)时,保留所有用户聊天记录, // 但过时的系统/游戏通知只保留最近 MAX_SYSTEM_BURST 条。 if (state.pendingChatMessages.length > MAX_SYSTEM_BURST + 30) { const now = Date.now(); const maxAge = MAX_SYSTEM_AGE_MINUTES * 60 * 1000; const totalSystem = state.pendingChatMessages.filter((m) => !isUserChatMessage(m)).length; let systemSeen = 0; let dropped = 0; const filtered = state.pendingChatMessages.filter((msg) => { if (isUserChatMessage(msg)) return true; systemSeen++; // 超过10分钟的系统通知直接丢弃 let msgTime = 0; if (msg.sent_at) { msgTime = new Date(msg.sent_at.replace(" ", "T")).getTime(); } if (msgTime > 0 && now - msgTime > maxAge) { dropped++; return false; } // 从旧到新遍历,只保留最后 MAX_SYSTEM_BURST 条系统通知 const remainingAfter = totalSystem - systemSeen; if (remainingAfter >= MAX_SYSTEM_BURST) { dropped++; return false; } return true; }); if (dropped > 0) { const container = state.container; if (container) { const notice = document.createElement("div"); notice.className = "msg-line msg-burst-notice"; notice.style.cssText = `text-align:center;padding:6px 0;margin:4px 0;font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;`; notice.textContent = `⏫ 省略了 ${dropped} 条系统通知`; container.appendChild(notice); } } state.pendingChatMessages = filtered; } 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 };