Files
chatroom/resources/js/chat-room/message-renderer.js
T
2026-04-30 15:07:09 +08:00

1122 lines
52 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 {
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 `<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) + "】";
});
}
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildGameLabelChipHtml(label, accentColor) {
return `<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor};">${escapeHtml(label)}</span>`;
}
/**
* 判断当前是否为礼包发放公告。
*/
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(/<button\b([^>]*)>([\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(/<button\b[\s\S]*?<\/button>/giu, "")
.replace(/<\/?b>/giu, "")
.replace(/^🧧\s*/u, "")
.trim();
const summary = escapeHtml(textOnlyContent);
const actionButtonHtml = `<button type="button"${buttonOnclick ? ` onclick="${escapeHtml(buttonOnclick)}"` : ""} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor};cursor:pointer;box-shadow:none;vertical-align:middle;">${escapeHtml(buttonLabel)}</button>`;
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${colorPalette.softBackground};border:1px solid ${colorPalette.softBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${accentColor};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${colorPalette.softBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${colorPalette.text};">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex-shrink:0;">
${buildGameLabelChipHtml("礼包红包", accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${colorPalette.chipBackground};color:${colorPalette.chipText};font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${colorPalette.chipBorder};">${escapeHtml(typeLabel)}</span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summary}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
${actionButtonHtml}
</div>
</div>
</div>
`;
}
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
const { activityLabel, typeLabel } = buildQuizActivityTitle(msg);
return `
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
${buildGameLabelChipHtml(activityLabel, accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
</span>
`;
}
/**
* 判断当前公屏消息是否属于“我自己”的钓鱼结果广播。
*
* 说明:
* - 收竿后,钓鱼者本人已经会在包厢窗口收到本地结果提示;
* - 这里需要把同一条公屏广播对本人隐藏,避免自己同时看到两条。
*/
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("【座驾】")) {
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.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, "")
.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(/<button\b([^>]*)>([\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 `<button type="button"${onclickAttr} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:#fff;color:${meta.accent};font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${meta.accent};cursor:pointer;box-shadow:none;vertical-align:middle;">${safeLabel}</button>`;
});
}
/**
* 将系统传音中的游戏通知渲染为和猜谜活动同级的紧凑卡片。
*/
function buildSystemGameNotificationHtml(msg, timeStr) {
const content = String(msg.content || "");
const meta = resolveGameNotificationCardMeta(msg);
if (!meta) {
return "";
}
const summary = normalizeSystemGameCardActions(extractSystemGameCardSummary(content, meta), meta);
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${meta.background};border:1px solid ${meta.border};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${meta.accent};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${meta.border};flex-shrink:0;">${meta.icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${meta.text};">
${buildGameLabelChipHtml(meta.label, meta.accent)}
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${parseBracketUsers(summary, meta.text)}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;font-weight:600;">(${timeStr})</span>
</div>
</div>
</div>
`;
}
/**
* 猜谜活动开题消息统一渲染为卡片。
*/
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 `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:linear-gradient(135deg,#f5f3ff,#faf5ff);border:1px solid rgba(124,58,237,.16);box-shadow:0 4px 12px rgba(124,58,237,.07);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px rgba(124,58,237,.16);flex-shrink:0;">🧩</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:#312e81;">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg)}</div>
<div data-quiz-inline-text style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${safeHint}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
<span data-quiz-inline-action-anchor></span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:#6d28d9;font-size:${CHAT_NOTICE_META_FONT_SIZE};flex-shrink:0;margin-left:auto;">
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
</div>
</div>
`;
}
/**
* 提取猜谜活动结束消息里的正确答案,兼容中奖与超时文案。
*/
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 `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${accentBackground};border:1px solid ${accentBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${iconBackground};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${accentBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${textColor};">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg, badgeColor)}</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summaryHtml}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
</div>
${isAnsweredResult ? `
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:${textColor};font-size:${CHAT_NOTICE_META_FONT_SIZE};flex-shrink:0;margin-left:auto;">
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px ${isAnsweredResult ? "rgba(124,58,237,.10)" : "rgba(217,119,6,.12)"};white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px ${isAnsweredResult ? "rgba(124,58,237,.10)" : "rgba(217,119,6,.12)"};white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
` : ""}
</div>
</div>
`;
}
/**
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
*/
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
? `<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:${CHAT_NOTICE_BUTTON_FONT_SIZE};">🖼️ 图片已过期</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);
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 = '<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 === "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 = `
<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:${CHAT_NOTICE_LARGE_ICON_FONT_SIZE};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:${CHAT_NOTICE_CHIP_FONT_SIZE};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:${CHAT_NOTICE_CHIP_FONT_SIZE};color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:${CHAT_NOTICE_DECOR_ICON_FONT_SIZE}; 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: ${CHAT_NOTICE_META_FONT_SIZE}; font-weight: normal;">(${timeStr})</span></div>`;
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 = `<div style="font-size: ${CHAT_NOTICE_BODY_FONT_SIZE}; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: ${CHAT_NOTICE_META_FONT_SIZE}; 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 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 = `
<div style="display:flex;align-items:flex-start;gap:10px;">
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_LARGE_ICON_FONT_SIZE};box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#d97706")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:7px;color:#9a3412;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
</div>
</div>
`;
timeStrOverride = true;
} else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (systemGameCardMeta) {
html = buildSystemGameNotificationHtml(msg, timeStr);
timeStrOverride = true;
} 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 (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 = `<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;";
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;
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 };