2026-04-27 09:19:49 +00:00
// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml , normalizeSafeChatUrl } from "./html.js" ;
2026-04-29 13:35:20 +08:00
import {
attachIdiomAnswerButton ,
buildQuizActivityTitle ,
disableIdiomAnswerButtons ,
isQuizStartMessage ,
normalizeQuizRoundPayload ,
} from "./riddle-quiz.js" ;
2026-04-27 09:19:49 +00:00
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 = [ "五子棋" , "双色球" , "钓鱼" , "老虎机" , "百家乐" , "赛马" ];
2026-04-29 18:27:32 +08:00
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" ;
2026-04-27 09:19:49 +00:00
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 ) + "】" ;
});
}
2026-04-29 14:35:52 +08:00
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildGameLabelChipHtml ( label , accentColor ) {
2026-04-29 18:27:32 +08:00
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>` ;
2026-04-29 14:35:52 +08:00
}
2026-04-29 15:06:01 +08:00
/**
* 判断当前是否为礼包发放公告。
*/
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 ( "经验的礼包" );
2026-04-29 15:23:32 +08:00
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 ;
2026-04-29 15:06:01 +08:00
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 );
2026-04-29 18:27:32 +08:00
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>` ;
2026-04-29 15:06:01 +08:00
return `
2026-04-29 15:23:32 +08:00
<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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 15:23:32 +08:00
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color: ${ colorPalette . text } ;">
2026-04-29 15:06:01 +08:00
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex-shrink:0;">
${ buildGameLabelChipHtml ( "礼包红包" , accentColor ) }
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 15:06:01 +08:00
</div>
2026-04-29 18:27:32 +08:00
<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;">
2026-04-29 15:06:01 +08:00
<span> ${ summary } </span>
2026-04-29 18:27:32 +08:00
<span class="msg-time" style="font-size: ${ CHAT_NOTICE_META_FONT_SIZE } ;color:#94a3b8;">( ${ timeStr } )</span>
2026-04-29 15:06:01 +08:00
${ actionButtonHtml }
</div>
</div>
</div>
` ;
}
2026-04-29 13:35:20 +08:00
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildQuizBadgeHtml ( msg , accentColor = "#7c3aed" ) {
const { activityLabel , typeLabel } = buildQuizActivityTitle ( msg );
return `
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
2026-04-29 14:35:52 +08:00
${ buildGameLabelChipHtml ( activityLabel , accentColor ) }
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 13:35:20 +08:00
</span>
` ;
}
2026-04-29 15:23:32 +08:00
/**
* 判断当前公屏消息是否属于“我自己”的钓鱼结果广播。
*
* 说明:
* - 收竿后,钓鱼者本人已经会在包厢窗口收到本地结果提示;
* - 这里需要把同一条公屏广播对本人隐藏,避免自己同时看到两条。
*/
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 ;
}
2026-04-29 14:35:52 +08:00
/**
* 判断当前消息是否应该使用统一的游戏通知卡片。
*/
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 ( "🏆 冠军:" )
2026-04-29 16:07:26 +08:00
|| /^🐎\s*第\s*#?\d+\s*场开赛/u . test ( normalizedContent )
|| /^🏇\s*第\s*#?\d+\s*场比赛开始/u . test ( normalizedContent )
|| /^🏆\s*第\s*#?\d+\s*场结束/u . test ( normalizedContent )
2026-04-29 14:35:52 +08:00
) {
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 === "百家乐" ) {
2026-04-29 15:06:01 +08:00
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 ();
}
2026-04-29 14:35:52 +08:00
return normalizedContent
2026-04-29 15:06:01 +08:00
. replace ( /^[🎲🎉]+\s*/u , "" )
2026-04-29 14:35:52 +08:00
. replace ( /^【百家乐】/u , "" )
. replace ( /\s+/gu , " " )
. trim ();
}
if ( meta . label === "赛马" ) {
return normalizedContent
2026-04-29 15:06:01 +08:00
. replace ( /^[🐎🏇🏆]+\s*/u , "" )
2026-04-29 14:35:52 +08:00
. replace ( /^【赛马】/u , "" )
. trim ();
}
if ( meta . label === "双色球彩票" ) {
return normalizedContent
. replace ( /^[🎟️🎊]+\s*/u , "" )
. replace ( /^【双色球[^】]*】/u , "" )
. trim ();
}
if ( meta . label === "五子棋" ) {
return normalizedContent
. replace ( /^[♟️🏆]+\s*/u , "" )
. replace ( /^【五子棋】/u , "" )
2026-04-29 15:06:01 +08:00
. replace ( /^玩家对战结果!/u , "对战结果:" )
. replace ( /^棋神降临!/u , "人机获胜:" )
. replace ( /^AI 大获全胜!/u , "AI获胜:" )
2026-04-29 14:35:52 +08:00
. trim ();
}
if ( meta . label === "老虎机" ) {
return normalizedContent
. replace ( /^[🎰🎉]+\s*/u , "" )
2026-04-29 15:06:01 +08:00
. replace ( /^【老虎机大奖】/u , "大奖:" )
2026-04-29 14:35:52 +08:00
. 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 ();
2026-04-29 18:27:32 +08:00
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>` ;
2026-04-29 14:35:52 +08:00
});
}
/**
* 将系统传音中的游戏通知渲染为和猜谜活动同级的紧凑卡片。
*/
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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 14:35:52 +08:00
<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 ) }
2026-04-29 18:27:32 +08:00
<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;">
2026-04-29 14:35:52 +08:00
<span> ${ parseBracketUsers ( summary , meta . text ) } </span>
2026-04-29 18:27:32 +08:00
<span class="msg-time" style="font-size: ${ CHAT_NOTICE_META_FONT_SIZE } ;color:#94a3b8;font-weight:600;">( ${ timeStr } )</span>
2026-04-29 14:35:52 +08:00
</div>
</div>
</div>
` ;
}
2026-04-29 13:35:20 +08:00
/**
* 猜谜活动开题消息统一渲染为卡片。
*/
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 `
2026-04-29 14:35:52 +08:00
<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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 14:35:52 +08:00
<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>
2026-04-29 18:27:32 +08:00
<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;">
2026-04-29 13:35:20 +08:00
<span> ${ safeHint } </span>
2026-04-29 18:27:32 +08:00
<span class="msg-time" style="font-size: ${ CHAT_NOTICE_META_FONT_SIZE } ;color:#94a3b8;">( ${ timeStr } )</span>
2026-04-29 13:35:20 +08:00
<span data-quiz-inline-action-anchor></span>
</div>
2026-04-29 18:27:32 +08:00
<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;">
2026-04-29 14:35:52 +08:00
<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>
2026-04-29 13:35:20 +08:00
</div>
</div>
</div>
` ;
}
/**
2026-04-29 14:35:52 +08:00
* 提取猜谜活动结束消息里的正确答案,兼容中奖与超时文案。
*/
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 ();
}
/**
* 猜谜活动结束消息统一渲染为和开题通知同级的紧凑卡片。
2026-04-29 13:35:20 +08:00
*/
function buildQuizResultHtml ( msg , timeStr ) {
const quizMeta = normalizeQuizRoundPayload ( msg );
2026-04-29 14:35:52 +08:00
const winnerUsername = String ( msg . winner_username || "" ). trim ();
const answerText = escapeHtml ( resolveQuizResultAnswerText ( msg ));
const isAnsweredResult = winnerUsername !== "" ;
2026-04-29 15:50:58 +08:00
const accentColor = isAnsweredResult ? "#7c3aed" : "#d97706" ;
2026-04-29 14:35:52 +08:00
const accentBackground = isAnsweredResult
2026-04-29 15:50:58 +08:00
? "linear-gradient(135deg,#f5f3ff,#faf5ff)"
2026-04-29 14:35:52 +08:00
: "linear-gradient(135deg,#fff7ed,#fffbeb)" ;
2026-04-29 15:50:58 +08:00
const accentBorder = isAnsweredResult ? "rgba(124,58,237,.16)" : "rgba(217,119,6,.18)" ;
const textColor = isAnsweredResult ? "#312e81" : "#9a3412" ;
2026-04-29 14:35:52 +08:00
const icon = isAnsweredResult ? "🎉" : "⏳" ;
const iconBackground = isAnsweredResult
2026-04-29 15:50:58 +08:00
? "linear-gradient(135deg,#7c3aed,#a78bfa)"
2026-04-29 14:35:52 +08:00
: "linear-gradient(135deg,#f59e0b,#f97316)" ;
2026-04-29 15:50:58 +08:00
const badgeColor = isAnsweredResult ? "#7c3aed" : "#d97706" ;
2026-04-29 14:35:52 +08:00
const summaryHtml = isAnsweredResult
2026-04-29 15:50:58 +08:00
? `【 ${ clickableUser ( winnerUsername , "#6d28d9" ) } 】率先答对「 ${ answerText } 」`
2026-04-29 14:35:52 +08:00
: `第 # ${ quizMeta . endedRoundId || quizMeta . roundId || 0 } 题已超时结束,正确答案: ${ answerText } ` ;
2026-04-29 13:35:20 +08:00
return `
2026-04-29 15:06:01 +08:00
<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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 15:06:01 +08:00
<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>
2026-04-29 18:27:32 +08:00
<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;">
2026-04-29 14:35:52 +08:00
<span> ${ summaryHtml } </span>
2026-04-29 18:27:32 +08:00
<span class="msg-time" style="font-size: ${ CHAT_NOTICE_META_FONT_SIZE } ;color:#94a3b8;">( ${ timeStr } )</span>
2026-04-29 13:35:20 +08:00
</div>
2026-04-29 14:35:52 +08:00
${ isAnsweredResult ? `
2026-04-29 18:27:32 +08:00
<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;">
2026-04-29 15:50:58 +08:00
<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>
2026-04-29 14:35:52 +08:00
</div>
` : "" }
2026-04-29 13:35:20 +08:00
</div>
</div>
` ;
}
2026-04-29 10:32:12 +08:00
/**
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
*/
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 ();
}
}
2026-04-27 09:19:49 +00:00
/**
* 构建聊天消息的内容 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);">
2026-04-28 10:33:49 +08:00
<img src=" ${ thumbUrl } " alt=" ${ imageName } " loading="lazy" decoding="async"
2026-04-27 09:19:49 +00:00
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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-27 09:19:49 +00:00
${ 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 );
2026-04-29 15:23:32 +08:00
if ( isOwnFishingResultBroadcast ( msg )) {
return null ;
}
2026-04-29 13:35:20 +08:00
const quizMeta = normalizeQuizRoundPayload ( msg );
const idiomRoundId = quizMeta . roundId ;
const isIdiomStartMessage = isQuizStartMessage ( msg )
&& [ "星海小博士" , "系统传音" ]. includes ( String ( msg . from_user || "" ));
2026-04-29 11:25:57 +08:00
if ( isIdiomStartMessage ) {
const existingIdiomNode = document . querySelector ( `[data-idiom-round-id=" ${ idiomRoundId } "]` );
if ( existingIdiomNode ) {
attachIdiomAnswerButton ( existingIdiomNode , msg );
return existingIdiomNode ;
}
}
2026-04-27 09:19:49 +00:00
const isMe = msg . from_user === window . chatContext ? . username ;
2026-04-29 16:51:28 +08:00
// 系统播报屏蔽只作用于公屏窗口;自己相关消息仍要进入包厢窗口,避免屏蔽误伤个人提示。
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 ;
2026-04-27 09:19:49 +00:00
const fontColor = msg . font_color || "#000000" ;
const blockRuleKey = resolveBlockedSystemSenderKey ( msg );
2026-04-29 16:51:28 +08:00
const shouldApplyBlockRule = Boolean ( blockRuleKey && ! isRelatedToMe );
const shouldHideByBlock = shouldApplyBlockRule ? state . blockedSystemSenders . has ( blockRuleKey ) : false ;
2026-04-27 09:19:49 +00:00
const div = document . createElement ( "div" );
div . className = "msg-line" ;
if ( msg ? . from_user ) {
div . dataset . fromUser = msg . from_user ;
}
2026-04-29 11:25:57 +08:00
if ( idiomRoundId > 0 ) {
div . dataset . idiomRoundId = String ( idiomRoundId );
2026-04-29 13:35:20 +08:00
div . dataset . quizRoundId = String ( idiomRoundId );
2026-04-29 11:25:57 +08:00
}
2026-04-29 16:51:28 +08:00
if ( shouldApplyBlockRule ) {
2026-04-27 09:19:49 +00:00
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 } ` ;
2026-04-29 10:32:12 +08:00
} else if ( msg . action === "idiom_result" ) {
div . dataset . idiomResult = "1" ;
2026-04-29 13:35:20 +08:00
div . dataset . quizRoundEndedId = String ( quizMeta . endedRoundId || quizMeta . roundId || 0 );
div . dataset . quizWinnerUsername = String ( msg . winner_username || "" );
2026-04-29 14:35:52 +08:00
html = buildQuizResultHtml ( msg , timeStr );
timeStrOverride = true ;
2026-04-29 13:35:20 +08:00
} else if ( isIdiomStartMessage ) {
html = buildQuizStartHtml ( msg , timeStr );
timeStrOverride = true ;
2026-04-27 09:19:49 +00:00
} 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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-27 09:19:49 +00:00
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-27 09:19:49 +00:00
</div>
2026-04-29 18:27:32 +08:00
<div style="margin-top:4px;font-size: ${ CHAT_NOTICE_BODY_FONT_SIZE } ;line-height:1.6;color:#1e293b;font-weight:500;"> ${ safeText } </div>
2026-04-27 09:19:49 +00:00
</div>
2026-04-29 18:27:32 +08:00
<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>
2026-04-27 09:19:49 +00:00
</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);" ;
2026-04-28 11:02:53 +08:00
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" );
2026-04-29 18:27:32 +08:00
html = `<div style="color: #1e40af;">💬 ${ clickablePrefix }${ parsedBody } <span style="color: #93c5fd; font-size: ${ CHAT_NOTICE_META_FONT_SIZE } ; font-weight: normal;">( ${ timeStr } )</span></div>` ;
2026-04-27 09:19:49 +00:00
timeStrOverride = true ;
} else if ( SYSTEM_USERS . includes ( msg . from_user )) {
if ( msg . from_user === "系统公告" ) {
2026-04-29 15:06:01 +08:00
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" );
2026-04-29 18:27:32 +08:00
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>` ;
2026-04-29 15:06:01 +08:00
timeStrOverride = true ;
}
2026-04-27 09:19:49 +00:00
} 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 ( "使用补签卡补签" );
2026-04-29 14:35:52 +08:00
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 ) {
2026-04-29 13:35:20 +08:00
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;">
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 13:35:20 +08:00
<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>
2026-04-29 18:27:32 +08:00
<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>
2026-04-29 13:35:20 +08:00
</div>
</div>
` ;
timeStrOverride = true ;
} else if ( isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification ) {
2026-04-27 09:19:49 +00:00
let plainAccentContent = parseBracketUsers ( msg . content );
html = `<span style="color: #b45309;">🌟 ${ plainAccentContent } </span>` ;
2026-04-29 14:35:52 +08:00
} else if ( systemGameCardMeta ) {
html = buildSystemGameNotificationHtml ( msg , timeStr );
timeStrOverride = true ;
2026-04-27 09:19:49 +00:00
} 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>` ;
}
2026-04-29 14:35:52 +08:00
} else if ( resolveGameNotificationCardMeta ( msg )) {
html = buildSystemGameNotificationHtml ( msg , timeStr );
timeStrOverride = true ;
2026-04-27 09:19:49 +00:00
} 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 =
2026-04-29 18:27:32 +08:00
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;" ;
2026-04-27 09:19:49 +00:00
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 );
2026-04-27 09:36:35 +00:00
const everyoneHtml = clickableUser ( "大家" , "#000099" );
2026-04-27 09:19:49 +00:00
const verbStr = msg . action ?
2026-04-27 09:36:35 +00:00
buildActionStr ( msg . action , fromHtml , everyoneHtml ) :
` ${ fromHtml } 对 ${ everyoneHtml } 说:` ;
2026-04-27 09:19:49 +00:00
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 ;
2026-04-29 10:32:12 +08:00
attachIdiomAnswerButton ( div , msg );
2026-04-29 13:35:20 +08:00
// 历史消息恢复或实时结算时,都立即把对应回合按钮置为结束态,保留消息结构便于回看。
if ( quizMeta . endedRoundId > 0 ) {
disableIdiomAnswerButtons ( quizMeta . endedRoundId , "本回合已结束" , String ( msg . winner_username || "" ));
2026-04-29 10:32:12 +08:00
}
2026-04-27 09:19:49 +00:00
// 命中屏蔽规则时,消息仍保留在 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 ;
2026-04-29 10:32:12 +08:00
if ( msg . action === "idiom_result" ) {
renderBatch . shouldPrunePrivateIdiomResults = true ;
}
2026-04-27 09:19:49 +00:00
return ;
}
const container2 = state . container2 ;
if ( container2 ) {
container2 . appendChild ( div );
pruneMessageContainer ( container2 , PRIVATE_MESSAGE_NODE_LIMIT );
2026-04-29 10:32:12 +08:00
if ( msg . action === "idiom_result" ) {
prunePrivateIdiomResultMessages ( container2 , 3 );
}
2026-04-27 09:19:49 +00:00
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 ,
2026-04-29 10:32:12 +08:00
shouldPrunePrivateIdiomResults : false ,
2026-04-27 09:19:49 +00:00
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 );
}
2026-04-29 10:32:12 +08:00
if ( renderBatch . shouldPrunePrivateIdiomResults ) {
const container2 = state . container2 ;
if ( container2 ) prunePrivateIdiomResultMessages ( container2 , 3 );
}
2026-04-27 09:19:49 +00:00
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 );
}
2026-04-28 11:59:01 +08:00
/**
* 判断是否为普通用户聊天消息(非系统/游戏通知)。
*/
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 ;
2026-04-28 11:55:35 +08:00
2026-04-27 09:19:49 +00:00
/**
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
*/
export function flushQueuedChatMessages () {
const state = window . chatState ;
if ( ! state ) return ;
state . chatMessageFlushTimer = null ;
2026-04-28 11:59:01 +08:00
// 大批量消息堆积(后台标签页恢复)时,保留所有用户聊天记录,
// 但过时的系统/游戏通知只保留最近 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 ;
2026-04-28 11:55:35 +08:00
2026-04-28 11:59:01 +08:00
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 =
2026-04-29 18:27:32 +08:00
`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;` ;
2026-04-28 11:59:01 +08:00
notice . textContent = `⏫ 省略了 ${ dropped } 条系统通知` ;
container . appendChild ( notice );
}
2026-04-28 11:55:35 +08:00
}
2026-04-28 11:59:01 +08:00
state . pendingChatMessages = filtered ;
2026-04-28 11:55:35 +08:00
}
2026-04-27 09:19:49 +00:00
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 };