重构猜谜活动并统一聊天室答题通知

This commit is contained in:
pllx
2026-04-29 13:35:20 +08:00
parent 192259f0a4
commit fe3e74b5f8
34 changed files with 3369 additions and 1833 deletions
+114 -19
View File
@@ -2,7 +2,13 @@
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { attachIdiomAnswerButton, removeIdiomAnswerButtons } from "./idiom-quiz.js";
import {
attachIdiomAnswerButton,
buildQuizActivityTitle,
disableIdiomAnswerButtons,
isQuizStartMessage,
normalizeQuizRoundPayload,
} from "./riddle-quiz.js";
import { isExpiredChatImageMessage } from "./message-utils.js";
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
import { escapePresenceText } from "./vip-presence.js";
@@ -50,6 +56,80 @@ function parseBracketUsers(content, color = "#000099") {
});
}
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
const { activityLabel, typeLabel } = buildQuizActivityTitle(msg);
return `
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:11px;font-weight:800;letter-spacing:.04em;">${escapeHtml(activityLabel)}</span>
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:11px;font-weight:700;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
</span>
`;
}
/**
* 猜谜活动开题消息统一渲染为卡片。
*/
function buildQuizStartHtml(msg, timeStr) {
const quizMeta = normalizeQuizRoundPayload(msg);
const rawHint = String(quizMeta.hint || msg.content || "")
.replace(/^🧩\s*/, "")
.replace(/^📣\s*/, "")
.replace(/^【[^】]+】\s*第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
.replace(/^【[^】]+】\s*/u, "")
.replace(/^第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
.replace(/^题面:\s*/u, "")
.trim();
const safeHint = escapeHtml(rawHint);
return `
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#faf5ff);border:1px solid rgba(124,58,237,.16);box-shadow:0 6px 16px rgba(124,58,237,.08);overflow:hidden;">
<div style="width:30px;height:30px;border-radius:10px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:17px;box-shadow:0 5px 12px rgba(124,58,237,.18);flex-shrink:0;">🧩</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#312e81;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg)}</div>
<div data-quiz-inline-text style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:13px;line-height:1.35;font-weight:700;min-width:220px;flex:1;">
<span>${safeHint}</span>
<span class="msg-time" style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
<span data-quiz-inline-action-anchor></span>
</div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;color:#6d28d9;font-size:11px;flex-shrink:0;margin-left:auto;">
<span style="padding:1px 7px;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 7px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
</div>
</div>
`;
}
/**
* 猜谜活动结算消息统一渲染为结果卡片。
*/
function buildQuizResultHtml(msg, timeStr) {
const quizMeta = normalizeQuizRoundPayload(msg);
const winnerHtml = clickableUser(String(msg.winner_username || ""), "#15803d");
const answerText = escapeHtml(quizMeta.answer || String(msg.content || ""));
return `
<div style="display:flex;align-items:flex-start;gap:12px;padding:10px 12px;border-radius:14px;background:linear-gradient(135deg,#f0fdf4,#ecfccb);border:1px solid rgba(22,163,74,.18);box-shadow:0 10px 24px rgba(34,197,94,.10);">
<div style="width:42px;height:42px;border-radius:12px;background:linear-gradient(135deg,#16a34a,#4ade80);display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;box-shadow:0 8px 18px rgba(22,163,74,.22);flex-shrink:0;">🎉</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#16a34a")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:8px;font-size:15px;line-height:1.75;color:#166534;font-weight:700;">【${winnerHtml}】率先答对「${answerText}」</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#166534;font-size:12px;">
<span style="padding:2px 8px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(22,163,74,.12);">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:2px 8px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(22,163,74,.12);">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
</div>
</div>
`;
}
/**
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
*/
@@ -121,14 +201,10 @@ export function appendMessage(msg, renderBatch = null) {
state.trackMaxMsgId(msg.id || 0);
const idiomRoundId = Number.parseInt(
String(msg.idiom_game_round_id || msg.idom_game_round_id || "0"),
10,
);
const isIdiomStartMessage = idiomRoundId > 0
&& msg.from_user === "星海小博士"
&& !msg.action
&& String(msg.content || "").includes("猜成语时间");
const quizMeta = normalizeQuizRoundPayload(msg);
const idiomRoundId = quizMeta.roundId;
const isIdiomStartMessage = isQuizStartMessage(msg)
&& ["星海小博士", "系统传音"].includes(String(msg.from_user || ""));
if (isIdiomStartMessage) {
const existingIdiomNode = document.querySelector(`[data-idiom-round-id="${idiomRoundId}"]`);
@@ -150,6 +226,7 @@ export function appendMessage(msg, renderBatch = null) {
}
if (idiomRoundId > 0) {
div.dataset.idiomRoundId = String(idiomRoundId);
div.dataset.quizRoundId = String(idiomRoundId);
}
if (blockRuleKey) {
div.dataset.blockKey = blockRuleKey;
@@ -210,12 +287,13 @@ export function appendMessage(msg, renderBatch = null) {
html = `${iconImg} ${parsedContent}`;
} else if (msg.action === "idiom_result") {
div.dataset.idiomResult = "1";
const winnerUsername = String(msg.winner_username || "");
const winnerHtml = clickableUser(winnerUsername, "#16a34a");
const answerText = escapeHtml(String(msg.idiom_answer || ""));
const rewardGold = Number.parseInt(String(msg.idiom_result_reward_gold ?? msg.reward_gold ?? 0), 10);
const rewardExp = Number.parseInt(String(msg.idiom_result_reward_exp ?? msg.reward_exp ?? 0), 10);
html = `<span style="color:#16a34a;font-weight:bold;">🎉 【${winnerHtml}】率先答对成语「${answerText}」,获得 ${rewardGold} 金币、${rewardExp} 经验!</span>`;
div.dataset.quizRoundEndedId = String(quizMeta.endedRoundId || quizMeta.roundId || 0);
div.dataset.quizWinnerUsername = String(msg.winner_username || "");
const 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>`;
} else if (isIdiomStartMessage) {
html = buildQuizStartHtml(msg, timeStr);
timeStrOverride = true;
} else if (msg.action === "vip_presence") {
const accent = msg.presence_color || "#f59e0b";
div.style.cssText =
@@ -278,6 +356,7 @@ export function appendMessage(msg, renderBatch = null) {
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
const isQuizStartNotification = isIdiomStartMessage || content.includes("猜谜活动") || content.includes("猜成语时间");
const isPlainNotification =
content.includes("【百家乐】") ||
content.includes("【赛马】") ||
@@ -287,7 +366,23 @@ export function appendMessage(msg, renderBatch = null) {
content.includes("【老虎机】") ||
content.includes("购买了");
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
if (isQuizStartNotification) {
div.style.cssText =
"background:linear-gradient(135deg,#fff7ed,#fffbeb);border:1px solid rgba(245,158,11,.28);border-left:4px solid #f59e0b;border-radius:12px;padding:8px 12px;margin:4px 0;box-shadow:0 10px 24px rgba(245,158,11,.14);";
html = `
<div style="display:flex;align-items:flex-start;gap:10px;">
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#d97706")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:7px;color:#9a3412;font-size:15px;font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
</div>
</div>
`;
timeStrOverride = true;
} else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (isPlainNotification) {
@@ -346,9 +441,9 @@ export function appendMessage(msg, renderBatch = null) {
div.innerHTML = html;
attachIdiomAnswerButton(div, msg);
// 历史消息恢复或实时结算时,都立即移除对应回合的旧答题按钮。
if (Number.parseInt(String(msg.idiom_game_round_ended_id || "0"), 10) > 0) {
removeIdiomAnswerButtons(Number.parseInt(String(msg.idiom_game_round_ended_id), 10));
// 历史消息恢复或实时结算时,都立即对应回合按钮置为结束态,保留消息结构便于回看
if (quizMeta.endedRoundId > 0) {
disableIdiomAnswerButtons(quizMeta.endedRoundId, "本回合已结束", String(msg.winner_username || ""));
}
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。