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" ,
} ;
}
2026-04-30 11:27:53 +08:00
if ( normalizedContent . includes ( "【座驾】" ) ) {
return {
label : "座驾" ,
icon : "🚀" ,
accent : "#0f766e" ,
background : "linear-gradient(135deg,#ecfeff,#f0fdfa)" ,
border : "rgba(15,118,110,.16)" ,
text : "#115e59" ,
chipBg : "#ccfbf1" ,
} ;
}
2026-04-29 14:35:52 +08:00
if (
normalizedContent . includes ( "【赛马】" )
|| normalizedContent . startsWith ( "🐎 开赛:" )
|| normalizedContent . startsWith ( "🏇 比赛开始:" )
|| normalizedContent . startsWith ( "🏆 冠军:" )
2026-04-29 16:07:26 +08:00
|| /^🐎\s*第\s*#?\d+\s*场开赛/u . test ( normalizedContent )
2026-04-30 15:07:09 +08:00
|| /^🏇\s*(?:赛马)?第\s*#?\d+\s*场比赛开始/u . test ( normalizedContent )
2026-04-29 16:07:26 +08:00
|| /^🏆\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 ( ) ;
}
2026-04-30 11:27:53 +08:00
if ( meta . label === "座驾" ) {
return normalizedContent
. replace ( /^[🚀]+\s*/u , "" )
. replace ( /^【座驾】/u , "" )
. replace ( /\s+/gu , " " )
. trim ( ) ;
}
2026-04-29 14:35:52 +08:00
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 } ;