1115 lines
51 KiB
JavaScript
1115 lines
51 KiB
JavaScript
// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
|
||
// 从 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 = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"];
|
||
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:11px;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:11px;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:13px;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:11px;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:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
|
||
<span>${summary}</span>
|
||
<span class="msg-time" style="font-size:10px;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:11px;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("【赛马】")
|
||
|| normalizedContent.startsWith("🐎 开赛:")
|
||
|| normalizedContent.startsWith("🏇 比赛开始:")
|
||
|| normalizedContent.startsWith("🏆 冠军:")
|
||
) {
|
||
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*#?\d+\s*局开奖!?\s*/u, "开奖:")
|
||
.replace(/总点\s*(\d+)\s*→\s*/u, "$1点 · ")
|
||
.replace(/本局无人获奖。?/u, "无人获奖")
|
||
.replace(/\s+/gu, " ")
|
||
.trim();
|
||
}
|
||
|
||
return normalizedContent
|
||
.replace(/^[🎲🎉]+\s*/u, "")
|
||
.replace(/^【百家乐】/u, "")
|
||
.replace(/\s+/gu, " ")
|
||
.trim();
|
||
}
|
||
|
||
if (meta.label === "赛马") {
|
||
if (normalizedContent.startsWith("🐎 开赛:")) {
|
||
return normalizedContent.replace(/^[🐎]+\s*/u, "").trim();
|
||
}
|
||
|
||
if (normalizedContent.startsWith("🏇 比赛开始:")) {
|
||
return normalizedContent.replace(/^[🏇]+\s*/u, "").trim();
|
||
}
|
||
|
||
if (normalizedContent.startsWith("🏆 冠军:")) {
|
||
return normalizedContent.replace(/^[🏆]+\s*/u, "").trim();
|
||
}
|
||
|
||
return normalizedContent
|
||
.replace(/^[🐎🏇🏆]+\s*/u, "")
|
||
.replace(/^【赛马】/u, "")
|
||
.replace(/^第\s*#?\d+\s*场开始!?\s*/u, "开赛:")
|
||
.replace(/^第\s*#?\d+\s*场押注截止!?\s*/u, "比赛开始:")
|
||
.replace(/^第\s*#?\d+\s*场结束!?\s*/u, "冠军:")
|
||
.replace(/马匹已进入跑道,比赛开始!?/u, "比赛开始")
|
||
.trim();
|
||
}
|
||
|
||
if (meta.label === "双色球彩票") {
|
||
if (normalizedContent.includes("超级期")) {
|
||
return normalizedContent.replace(/^[🎊🎟️]+\s*/u, "").trim();
|
||
}
|
||
|
||
return normalizedContent
|
||
.replace(/^[🎟️🎊]+\s*/u, "")
|
||
.replace(/^【双色球[^】]*】/u, "")
|
||
.replace(/^(\d+\s*期:)/u, "开奖:$1")
|
||
.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:11px;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:13px;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:12px;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:10px;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:13px;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:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
|
||
<span>${safeHint}</span>
|
||
<span class="msg-time" style="font-size:10px;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:10px;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:13px;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:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
|
||
<span>${summaryHtml}</span>
|
||
<span class="msg-time" style="font-size:10px;color:#94a3b8;">(${timeStr})</span>
|
||
</div>
|
||
${isAnsweredResult ? `
|
||
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:${textColor};font-size:10px;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: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);
|
||
|
||
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 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 (idiomRoundId > 0) {
|
||
div.dataset.idiomRoundId = String(idiomRoundId);
|
||
div.dataset.quizRoundId = String(idiomRoundId);
|
||
}
|
||
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 === "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: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 === "系统公告") {
|
||
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: 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 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:20px;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:15px;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;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;
|
||
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);
|
||
}
|
||
|
||
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
|
||
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 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:12px;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 };
|