fix: 修复迁移遗留的按钮无响应、头像框层级及构建错误

迁移收尾修复:
- heartbeat.js: 移除 export { } 中重复的 startHeartbeat/stopHeartbeat(已通过 export function 导出)
- scripts.blade.php: 移除 JS 注释中的 {{ }} 避免 Blade 编译为 e() 导致 PHP 解析错误
- preferences-status.js: 补全 6 个缺失的 window.* 赋值(toggleBlockMenu/toggleFeatureMenu 等),
  实现迁移中丢失的 updateDailyStatus/clearDailyStatus,修复 handleFeatureLocalClear 清屏回调
- toolbar.js: 补全 window.runFeatureShortcut 赋值

头像框样式修复(chat-decorations.css):
- z-index 互换:头像降至 1,框升至 3,使框边缘可遮挡头像外围
- 使用 CSS mask(radial-gradient)挖环形替代旧 ::before 实心圆遮挡方案
- clip-path: circle(50%) 硬裁剪确保圆形,不受 chat.css border-radius: 2px 覆盖
- 特异性提升至 .user-item .avatar-frame-wrapper .user-head

新 Vite 模块(从 Blade 迁移):
- chat-state.js / message-renderer.js / user-list.js / chat-events.js
- composer.js(重写)/ heartbeat.js / admin-commands.js
- vip-presence.js / chat-decorations.css
This commit is contained in:
pllx
2026-04-27 09:19:49 +00:00
parent d10a354370
commit f17f171f4b
18 changed files with 3992 additions and 4105 deletions
+2
View File
@@ -144,3 +144,5 @@
transform: translateX(140%);
}
}
@import './chat-decorations.css';
+384
View File
@@ -0,0 +1,384 @@
/* ========== 消息气泡装扮:在原版逐行消息基础上增加纹理、角标和轻量动效 ========== */
.msg-line[class*="msg-bubble--"] {
position: relative;
isolation: isolate;
display: block;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
min-height: 24px;
margin: 4px 0;
padding: 5px 12px 5px 14px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(51, 102, 153, .16);
background: rgba(255, 255, 255, .72);
box-shadow: 0 1px 4px rgba(51, 102, 153, .12);
}
.msg-line[class*="msg-bubble--"]::before,
.msg-line[class*="msg-bubble--"]::after {
content: "";
position: absolute;
pointer-events: none;
z-index: 0;
}
.msg-line[class*="msg-bubble--"] > * {
position: relative;
z-index: 1;
}
.msg-bubble--golden {
border-color: rgba(217, 119, 6, .32) !important;
background:
linear-gradient(90deg, rgba(245, 158, 11, .32) 0 4px, transparent 4px),
radial-gradient(circle at 28px 8px, rgba(255, 255, 255, .85), transparent 10px),
linear-gradient(135deg, #fff8df 0%, #fffdf5 56%, #fff1c2 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(217, 119, 6, .18);
}
.msg-bubble--golden::after {
top: 0;
bottom: 0;
left: -36px;
width: 36px;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .72), transparent);
animation: msg-bubble-shine 3.6s ease-in-out infinite;
}
.msg-bubble--sakura {
border-color: rgba(244, 114, 182, .32) !important;
background:
radial-gradient(circle at 18px 10px, rgba(244, 114, 182, .42) 0 2px, transparent 3px),
radial-gradient(circle at 44px 20px, rgba(251, 207, 232, .86) 0 3px, transparent 4px),
linear-gradient(135deg, #fff7fb 0%, #fff 48%, #ffe4f1 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(244, 114, 182, .14);
}
.msg-bubble--star {
border-color: rgba(79, 70, 229, .32) !important;
background:
radial-gradient(circle at 20px 9px, rgba(255, 255, 255, .9) 0 1px, transparent 2px),
radial-gradient(circle at 76px 20px, rgba(99, 102, 241, .36) 0 2px, transparent 3px),
linear-gradient(135deg, #eef2ff 0%, #f8fbff 54%, #dbeafe 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .8), 0 2px 10px rgba(79, 70, 229, .16);
}
.msg-bubble--star::before {
right: 10px;
top: 5px;
width: 42px;
height: 16px;
background: radial-gradient(circle, rgba(67, 56, 202, .42) 0 1px, transparent 2px);
background-size: 11px 8px;
opacity: .72;
}
.msg-bubble--rainbow {
border-color: rgba(59, 130, 246, .22) !important;
background:
linear-gradient(#ffffffd9, #ffffffd9) padding-box,
linear-gradient(120deg, rgba(239, 68, 68, .16), rgba(245, 158, 11, .16), rgba(34, 197, 94, .16), rgba(59, 130, 246, .16), rgba(168, 85, 247, .16)) border-box !important;
box-shadow: 0 2px 10px rgba(59, 130, 246, .14);
}
.msg-bubble--rainbow::before {
left: 0;
right: 0;
top: 0;
height: 3px;
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);
background-size: 180% 100%;
animation: msg-bubble-rainbow 4.2s linear infinite;
}
.msg-bubble--crown {
border-color: rgba(180, 83, 9, .34) !important;
background:
linear-gradient(90deg, rgba(180, 83, 9, .24) 0 4px, transparent 4px),
radial-gradient(circle at right 12px top 8px, rgba(251, 191, 36, .36), transparent 18px),
linear-gradient(135deg, #fff7d6 0%, #fffdfa 46%, #fde68a 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 3px 12px rgba(180, 83, 9, .22);
}
.msg-bubble--crown::after {
content: "♛";
top: 2px;
right: 8px;
z-index: 0;
color: rgba(180, 83, 9, .26);
font-size: 18px;
line-height: 1;
}
@keyframes msg-bubble-shine {
0%, 62% { transform: translateX(0); opacity: 0; }
72% { opacity: .82; }
100% { transform: translateX(280px); opacity: 0; }
}
@keyframes msg-bubble-rainbow {
from { background-position: 0% 50%; }
to { background-position: 180% 50%; }
}
/* ========== 昵称颜色 ========== */
.msg-name--golden { color: #fbbf24 !important; font-weight: 700; }
.msg-name--rainbow {
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
}
.msg-name--glow {
color: #e2e8f0 !important;
text-shadow: 0 0 6px #818cf8, 0 0 14px #6366f1;
}
.msg-name--flame {
color: #f97316 !important;
font-weight: 700;
animation: name-flame 1.5s ease-in-out infinite;
}
@keyframes name-flame {
0%, 100% { text-shadow: 0 0 4px #ef4444; }
50% { text-shadow: 0 0 10px #fbbf24, 0 0 16px #ef4444; }
}
/* ========== 消息文字颜色特效 ========== */
.msg-text--rainbow {
background: linear-gradient(90deg,
#ef4444, #f97316, #eab308, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
background-size: 300% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
animation: text-rainbow 3.5s linear infinite;
}
@keyframes text-rainbow {
0% { background-position: 0% 50%; }
100% { background-position: 300% 50%; }
}
.msg-text--shimmer {
background: linear-gradient(110deg,
#6b7280 0%, #9ca3af 18%, #f3f4f6 28%, #d1d5db 36%,
#6b7280 52%, #9ca3af 66%, #f9fafb 74%, #6b7280 100%);
background-size: 300% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
animation: text-shimmer 4s ease-in-out infinite;
}
@keyframes text-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: -200% 50%; }
}
.msg-text--neon {
color: #a855f7 !important;
font-weight: 600;
text-shadow:
0 0 7px #a855f7,
0 0 14px #7c3aed,
0 0 28px #6366f1,
0 0 42px #4f46e5;
animation: text-neon 2s ease-in-out infinite alternate;
}
@keyframes text-neon {
0% { text-shadow: 0 0 7px #a855f7, 0 0 14px #7c3aed, 0 0 28px #6366f1; }
100% { text-shadow: 0 0 14px #c084fc, 0 0 28px #a855f7, 0 0 48px #7c3aed, 0 0 64px #6366f1; }
}
.msg-text--flame {
background: linear-gradient(180deg, #fef3c7 0%, #f59e0b 28%, #ea580c 60%, #dc2626 100%);
background-size: 100% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
animation: text-flame 1.2s ease-in-out infinite alternate;
}
@keyframes text-flame {
0% { background-position: 0% 0%; }
100% { background-position: 0% 100%; }
}
.msg-text--ice {
background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 25%, #38bdf8 50%, #bae6fd 75%, #e0f2fe 100%);
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
animation: text-ice 3s ease-in-out infinite;
filter: drop-shadow(0 0 4px rgba(56, 189, 248, 0.5));
}
@keyframes text-ice {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* ========== 头像框 ========== */
.avatar-frame-wrapper {
position: relative;
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
flex: 0 0 44px;
line-height: 0;
overflow: hidden;
border-radius: 50%;
}
.user-item .avatar-frame-wrapper .user-head {
position: relative;
z-index: 1;
width: 36px;
height: 36px;
border-radius: 50%;
mix-blend-mode: normal;
overflow: hidden;
/* clip-path 硬裁剪保证头像在任何情况下都是正圆形,不受外部 border-radius 覆盖影响 */
clip-path: circle(50% at 50% 50%);
/* 头像置于头像框下方框边缘可遮挡头像外围
提高特异性覆盖 chat.css 中的 .user-item .user-head */
}
.avatar-frame {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 3;
/* 使用 mask 在 44px 圆上挖出 4px 宽的环形:中心透明(头像可见),边缘渐变色可见 */
-webkit-mask-image: radial-gradient(circle closest-side at center, transparent 18px, black 19px);
mask-image: radial-gradient(circle closest-side at center, transparent 18px, black 19px);
}
.avatar-frame::before,
.avatar-frame::after {
content: "";
position: absolute;
pointer-events: none;
}
.avatar-frame--silver {
background: conic-gradient(from 25deg, #ffffff, #94a3b8, #e2e8f0, #64748b, #ffffff);
box-shadow: 0 0 0 1px rgba(148, 163, 184, .38), 0 2px 8px rgba(100, 116, 139, .24);
}
.avatar-frame--silver::before,
.avatar-frame--gold::before,
.avatar-frame--star::before,
.avatar-frame--dragon::before {
inset: 4px;
border-radius: 50%;
/* 旧方案依靠 ::before 实心圆遮挡渐变背景形成圆环
现改用 mask 挖空中心::before 不再需要遮挡 */
}
.avatar-frame--gold {
background: conic-gradient(from -20deg, #fff7ad, #f59e0b, #fff1a6, #b45309, #fff7ad);
box-shadow: 0 0 0 1px rgba(217, 119, 6, .34), 0 0 12px rgba(245, 158, 11, .38);
}
.avatar-frame--star {
background:
radial-gradient(circle at 50% 0%, #ffffff 0 2px, transparent 3px),
conic-gradient(from 0deg, #fef08a, #818cf8, #ffffff, #fbbf24, #818cf8, #fef08a);
box-shadow: 0 0 14px rgba(129, 140, 248, .48);
animation: frame-rotate 4s linear infinite;
}
.avatar-frame--star::after {
inset: -2px;
border-radius: 50%;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 2px, transparent 3px);
transform-origin: 50% 50%;
}
.avatar-frame--dragon {
background:
conic-gradient(from 45deg, #7f1d1d, #f59e0b, #ef4444, #991b1b, #f59e0b, #7f1d1d);
box-shadow: 0 0 14px rgba(239, 68, 68, .42), 0 0 0 1px rgba(127, 29, 29, .38);
}
.avatar-frame--dragon::after {
inset: 5px;
border-radius: 50%;
border: 1px dashed rgba(254, 202, 202, .82);
}
@keyframes frame-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ========== 紧凑版头像框:用于聊天消息中的小头像(16px) ========== */
.avatar-frame-wrapper-sm {
position: relative;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
vertical-align: middle;
margin-right: 2px;
line-height: 0;
flex: 0 0 22px;
}
.avatar-frame-wrapper-sm .avatar-frame {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 3;
-webkit-mask-image: radial-gradient(circle closest-side at center, transparent 8px, black 9px);
mask-image: radial-gradient(circle closest-side at center, transparent 8px, black 9px);
}
.avatar-frame-wrapper-sm .avatar-frame::before,
.avatar-frame-wrapper-sm .avatar-frame::after {
content: "";
position: absolute;
pointer-events: none;
}
.avatar-frame-wrapper-sm .avatar-frame--silver::before,
.avatar-frame-wrapper-sm .avatar-frame--gold::before,
.avatar-frame-wrapper-sm .avatar-frame--star::before,
.avatar-frame-wrapper-sm .avatar-frame--dragon::before {
inset: 2px;
border-radius: 50%;
/* mask 已处理环形 */
}
.avatar-frame-wrapper-sm .avatar-frame--dragon::after {
inset: 3px;
border-radius: 50%;
border: 1px dashed rgba(254, 202, 202, .82);
}
.avatar-frame-wrapper-sm .avatar-frame--star::after {
inset: -1px;
border-radius: 50%;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 1px, transparent 2px);
transform-origin: 50% 50%;
}
.avatar-frame-wrapper-sm img {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
border-radius: 50%;
mix-blend-mode: normal;
overflow: hidden;
clip-path: circle(50% at 50% 50%);
/* 头像置于框下方 */
}
+95
View File
@@ -208,14 +208,17 @@ export {
} from "./chat-room/compact-shop-panel.js";
export { bindSlotMachineControls, slotFab, slotPanel } from "./chat-room/slot-machine.js";
export { bindVipControls, buyVip, closeVipModal, openVipModal, saveVipPresenceSettings, switchVipTab } from "./chat-room/vip-controls.js";
export { showVipPresenceBanner } from "./chat-room/vip-presence.js";
export {
BLOCKABLE_SYSTEM_SENDERS,
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
CHAT_SOUND_MUTED_STORAGE_KEY,
bindBlockMenuControls,
bindSoundMuteControl,
buildChatPreferencesPayload,
closeDailyStatusEditor,
closeFeatureMenu,
getCurrentUserDailyStatus,
handleFeatureLocalClear,
isSoundMuted,
loadBlockedSystemSenders,
@@ -224,9 +227,18 @@ export {
openDailyStatusEditor,
parseDailyStatusExpiry,
persistBlockedSystemSenders,
persistChatPreferencesToLocal,
removeDailyStatusFields,
resolveBlockedSystemSenderKey,
saveChatPreferences,
setOnlineUserDailyStatus,
setRenderedMessagesVisibilityBySender,
setSoundMuted,
shouldMigrateLocalChatPreferences,
syncBlockedSystemSenderCheckboxes,
syncDailyStatusUi,
toggleBlockMenu,
toggleBlockedSystemSender,
toggleFeatureMenu,
toggleSoundMute,
} from "./chat-room/preferences-status.js";
@@ -260,6 +272,30 @@ export {
toggleAutoScroll,
} from "./chat-room/message-utils.js";
// 新增:聊天室核心引擎模块导出
export {
appendMessage,
buildChatMessageContent,
commitChatMessageRenderBatch,
createChatMessageRenderBatch,
enqueueChatMessage,
flushQueuedChatMessages,
pruneMessageContainer,
} from "./chat-room/message-renderer.js";
export {
buildUserBadgeHtml,
filterUserList,
refreshRenderedUserBadges,
renderUserList,
renderUserListToContainer,
scheduleFilterUserList,
scheduleRenderUserList,
startBadgeRotation,
stopBadgeRotation,
} from "./chat-room/user-list.js";
export { bindChatEvents } from "./chat-room/chat-events.js";
export { leaveRoom, notifyExpiredLeave, saveExp, startHeartbeat, stopHeartbeat, HEARTBEAT_INTERVAL, MAX_HEARTBEAT_FAILS } from "./chat-room/heartbeat.js";
import { escapeHtml, escapeHtmlWithLineBreaks, normalizeSafeChatUrl } from "./chat-room/html.js";
import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js";
import { bindChatBanner } from "./chat-room/banner.js";
@@ -416,8 +452,10 @@ import {
CHAT_SOUND_MUTED_STORAGE_KEY,
bindBlockMenuControls,
bindSoundMuteControl,
buildChatPreferencesPayload,
closeDailyStatusEditor,
closeFeatureMenu,
getCurrentUserDailyStatus,
handleFeatureLocalClear,
isSoundMuted,
loadBlockedSystemSenders,
@@ -426,9 +464,18 @@ import {
openDailyStatusEditor,
parseDailyStatusExpiry,
persistBlockedSystemSenders,
persistChatPreferencesToLocal,
removeDailyStatusFields,
resolveBlockedSystemSenderKey,
saveChatPreferences,
setOnlineUserDailyStatus,
setRenderedMessagesVisibilityBySender,
setSoundMuted,
shouldMigrateLocalChatPreferences,
syncBlockedSystemSenderCheckboxes,
syncDailyStatusUi,
toggleBlockMenu,
toggleBlockedSystemSender,
toggleFeatureMenu,
toggleSoundMute,
} from "./chat-room/preferences-status.js";
@@ -462,6 +509,13 @@ import {
toggleAutoScroll,
} from "./chat-room/message-utils.js";
// 新增:聊天室核心引擎模块(共享状态、消息渲染、用户名单、事件监听、心跳)
import "./chat-room/chat-state.js";
import { appendMessage, buildChatMessageContent, commitChatMessageRenderBatch, createChatMessageRenderBatch, enqueueChatMessage, flushQueuedChatMessages, pruneMessageContainer } from "./chat-room/message-renderer.js";
import { buildUserBadgeHtml, filterUserList, refreshRenderedUserBadges, renderUserList, renderUserListToContainer, scheduleFilterUserList, scheduleRenderUserList, startBadgeRotation, stopBadgeRotation } from "./chat-room/user-list.js";
import { bindChatEvents } from "./chat-room/chat-events.js";
import { leaveRoom, notifyExpiredLeave, saveExp, startHeartbeat, stopHeartbeat } from "./chat-room/heartbeat.js";
if (typeof window !== "undefined") {
bindInstantHoverTooltip();
@@ -672,6 +726,17 @@ if (typeof window !== "undefined") {
toggleBlockMenu,
toggleFeatureMenu,
toggleSoundMute,
buildChatPreferencesPayload,
getCurrentUserDailyStatus,
persistChatPreferencesToLocal,
removeDailyStatusFields,
resolveBlockedSystemSenderKey,
saveChatPreferences,
setOnlineUserDailyStatus,
setRenderedMessagesVisibilityBySender,
syncBlockedSystemSenderCheckboxes,
syncDailyStatusUi,
toggleBlockedSystemSender,
bindChatRightPanelControls,
bindRoomStatusControls,
normalizeRoomStatus,
@@ -695,6 +760,26 @@ if (typeof window !== "undefined") {
scrollChatToBottom,
syncAutoScrollControls,
toggleAutoScroll,
// 聊天室核心引擎
bindChatEvents,
appendMessage,
buildChatMessageContent,
commitChatMessageRenderBatch,
createChatMessageRenderBatch,
enqueueChatMessage,
flushQueuedChatMessages,
pruneMessageContainer,
buildUserBadgeHtml,
filterUserList,
refreshRenderedUserBadges,
renderUserListToContainer,
startBadgeRotation,
stopBadgeRotation,
scheduleFilterUserList,
leaveRoom,
notifyExpiredLeave,
startHeartbeat,
stopHeartbeat,
};
// 直接挂载只服务暂未迁移的 Blade 调用点;新代码优先通过模块导入或 ChatRoomTools 复用。
@@ -834,6 +919,13 @@ if (typeof window !== "undefined") {
window.showShopToast = showShopToast;
window.submitRename = submitRename;
// 聊天室核心引擎 window 挂载
window.bindChatEvents = bindChatEvents;
window.startBadgeRotation = startBadgeRotation;
window.stopBadgeRotation = stopBadgeRotation;
window.startHeartbeat = startHeartbeat;
window.stopHeartbeat = stopHeartbeat;
// 页面加载后立即注册事件委托,具体业务逻辑仍由各子模块负责。
bindChatBanner();
bindChatBotControls();
@@ -884,4 +976,7 @@ if (typeof window !== "undefined") {
bindMobileDrawerControls();
bindWelcomeMenuControls();
bindBlockMenuControls();
bindChatEvents();
startBadgeRotation();
startHeartbeat();
}
+263
View File
@@ -0,0 +1,263 @@
// 聊天室管理员命令模块:设置公告、公屏讲话、清屏、刷新全员、特效触发。
// 从 Blade 内联脚本迁移至 Vite 管理。
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 设置房间公告。
*/
function promptAnnouncement() {
const fullText = document.getElementById("announcement-text")?.textContent?.trim() || "";
const pureText = fullText.replace(/ ——\S+ \d{2}-\d{2} \d{2}:\d{2}$/, "").trim();
window.chatDialog
.prompt("请输入新的房间公告/祝福语:", pureText, "设置公告", "#336699")
.then((newText) => {
if (newText === null || newText.trim() === "") return;
fetch(`/room/${window.chatContext.roomId}/announcement`, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ announcement: newText.trim() }),
})
.then((res) => res.json())
.then((data) => {
if (data.status === "success") {
const marquee = document.getElementById("announcement-text");
if (marquee) marquee.textContent = data.announcement;
window.chatDialog.alert("公告已更新!", "提示", "#16a34a");
} else {
window.chatDialog.alert(data.message || "更新失败", "操作失败", "#cc4444");
}
})
.catch((e) => {
window.chatDialog.alert("设置公告失败:" + e.message, "操作失败", "#cc4444");
});
});
}
/**
* 站长公屏讲话。
*/
function promptAnnounceMessage() {
window.chatDialog
.prompt("请输入公屏讲话内容:", "", "📢 公屏讲话", "#7c3aed")
.then((content) => {
if (!content || !content.trim()) return;
fetch("/command/announce", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
content: content.trim(),
room_id: window.chatContext.roomId,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.status !== "success") {
window.chatDialog.alert(data.message || "发送失败", "操作失败", "#cc4444");
}
})
.catch((e) => {
window.chatDialog.alert("发送失败:" + e.message, "操作失败", "#cc4444");
});
});
}
/**
* 管理员全员清屏。
*/
function adminClearScreen() {
window.chatDialog
.confirm("确定要清除所有人的聊天记录吗?(悄悄话将保留)", "全员清屏", "#dc2626")
.then((ok) => {
if (!ok) return;
fetch("/command/clear-screen", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ room_id: window.chatContext.roomId }),
})
.then((res) => res.json())
.then((data) => {
if (data.status !== "success") {
window.chatDialog.alert(data.message || "清屏失败", "操作失败", "#cc4444");
}
})
.catch((e) => {
window.chatDialog.alert("清屏失败:" + e.message, "操作失败", "#cc4444");
});
});
}
/**
* 管理员触发全屏特效。
*
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
*/
function triggerEffect(type) {
const roomId = window.chatContext?.roomId;
if (!roomId) return;
fetch("/command/effect", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": csrf(),
},
body: JSON.stringify({ room_id: roomId, type }),
})
.then((r) => r.json())
.then((data) => {
if (data.status !== "success")
window.chatDialog.alert(data.message, "操作失败", "#cc4444");
})
.catch((err) => console.error("特效触发失败:", err));
}
/**
* 选择特效后关闭菜单并触发。
*
* @param {string} type 特效类型
*/
function selectEffect(type) {
const menu = document.getElementById("admin-menu");
if (menu) menu.style.display = "none";
triggerEffect(type);
}
/**
* 站长通知当前房间所有在线用户刷新页面。
*/
async function refreshAllBrowsers() {
if (!window.chatContext?.isSiteOwner || !window.chatContext?.refreshAllUrl) {
window.chatDialog?.alert("仅站长可执行全员刷新。", "无权限", "#cc4444");
return;
}
const confirmed = await window.chatDialog?.confirm(
"确定通知当前房间所有在线用户刷新页面吗?\n适用于功能更新后强制同步最新按钮与权限状态。",
"♻️ 刷新全员",
"#0f766e",
);
if (!confirmed) return;
try {
const response = await fetch(window.chatContext.refreshAllUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
reason: "功能更新,站长要求刷新页面",
}),
});
const data = await response.json();
if (data.status === "success") {
window.chatToast?.show({
title: "已发送刷新通知",
message: data.message,
icon: "♻️",
color: "#0f766e",
duration: 3500,
});
return;
}
window.chatDialog?.alert(data.message || "发送刷新通知失败", "操作失败", "#cc4444");
} catch (_error) {
window.chatDialog?.alert("网络异常,请稍后再试", "错误", "#cc4444");
}
}
/**
* 执行管理菜单中的快捷操作,并在执行前关闭菜单。
*
* @param {string} action 管理动作类型
*/
function runAdminAction(action) {
const menu = document.getElementById("admin-menu");
if (menu) menu.style.display = "none";
switch (action) {
case "announcement":
promptAnnouncement();
break;
case "announce-message":
promptAnnounceMessage();
break;
case "admin-clear":
adminClearScreen();
break;
case "red-packet":
window.sendRedPacket?.();
break;
case "loss-cover":
window.openAdminBaccaratLossCoverModal?.();
break;
case "refresh-all":
refreshAllBrowsers();
break;
default:
break;
}
}
/**
* 切换管理菜单显示/隐藏。
*/
function toggleAdminMenu(event) {
event.stopPropagation();
const menu = document.getElementById("admin-menu");
const welcomeMenu = document.getElementById("welcome-menu");
const blockMenu = document.getElementById("block-menu");
const featureMenu = document.getElementById("feature-menu");
const dailyStatusEditor = document.getElementById("daily-status-editor-overlay");
if (!menu) return;
[welcomeMenu, blockMenu, featureMenu, dailyStatusEditor].forEach((el) => {
if (el) el.style.display = "none";
});
menu.style.display = menu.style.display === "none" ? "block" : "none";
}
// 挂载到 window 供 Blade 脚本及其他模块使用。
window.promptAnnouncement = promptAnnouncement;
window.promptAnnounceMessage = promptAnnounceMessage;
window.adminClearScreen = adminClearScreen;
window.triggerEffect = triggerEffect;
window.selectEffect = selectEffect;
window.refreshAllBrowsers = refreshAllBrowsers;
window.runAdminAction = runAdminAction;
window.toggleAdminMenu = toggleAdminMenu;
export {
promptAnnouncement,
promptAnnounceMessage,
adminClearScreen,
triggerEffect,
selectEffect,
refreshAllBrowsers,
runAdminAction,
toggleAdminMenu,
};
+3 -9
View File
@@ -1,11 +1,12 @@
// 聊天室管理菜单事件绑定,替代 input-bar 中的管理类内联 onclick。
// 管理动作业务逻辑已迁至 admin-commands.js。
import "./admin-commands.js";
let adminMenuEventsBound = false;
/**
* 绑定管理菜单、管理动作与全屏特效选择事件。
*
* @returns {void}
*/
export function bindAdminMenuControls() {
if (adminMenuEventsBound || typeof document === "undefined") {
@@ -22,33 +23,26 @@ export function bindAdminMenuControls() {
if (menuToggle) {
event.preventDefault();
window.toggleAdminMenu?.(event);
return;
}
const adminAction = event.target.closest("[data-chat-admin-action]");
if (adminAction) {
event.preventDefault();
// 管理菜单只负责入口分发,权限校验和实际动作仍由后端与原有全局函数负责。
const action = adminAction.getAttribute("data-chat-admin-action") || "";
if (action && typeof window.runAdminAction === "function") {
window.runAdminAction(action);
}
return;
}
const effectButton = event.target.closest("[data-chat-admin-effect]");
if (effectButton) {
event.preventDefault();
// 特效按钮只触发管理员发起请求,实际播放仍由 chat:effect 广播和 EffectManager 处理。
const effect = effectButton.getAttribute("data-chat-admin-effect") || "";
if (effect && typeof window.selectEffect === "function") {
window.selectEffect(effect);
}
return;
}
+469
View File
@@ -0,0 +1,469 @@
// 聊天室 WebSocket 事件监听,从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
// 所有事件委托通过 window.addEventListener 注册,依赖 window.chatState 共享状态。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { normalizeDailyStatus } from "./preferences-status.js";
import { enqueueChatMessage } from "./message-renderer.js";
// ── 事件注册标记 ──
let chatEventsBound = false;
// ── 辅助函数 ──
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
function getState() {
return window.chatState;
}
/**
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/
function initChatWebSocket() {
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
}
}
// ── 禁言逻辑 ──
function handleMutedEvent(e) {
const state = getState();
const d = e.detail;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const isMe = d.username === window.chatContext?.username;
const div = document.createElement("div");
div.className = "msg-line";
div.innerHTML = `<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
const targetContainer = isMe
? document.getElementById("say2")
: (state?.container);
if (targetContainer) {
targetContainer.appendChild(div);
targetContainer.scrollTop = targetContainer.scrollHeight;
}
if (isMe && d.mute_time > 0) {
state.isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
const contentInput = document.getElementById("content");
const operatorName = d.operator || "管理员";
if (contentInput) {
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
contentInput.disabled = true;
setTimeout(() => {
state.isMutedUntil = 0;
contentInput.placeholder = "在这里输入聊天内容,按 Enter 发送...";
contentInput.disabled = false;
const unmuteDiv = document.createElement("div");
unmuteDiv.className = "msg-line";
unmuteDiv.innerHTML = '<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
const say2 = document.getElementById("say2");
if (say2) {
say2.appendChild(unmuteDiv);
say2.scrollTop = say2.scrollHeight;
}
}, d.mute_time * 60 * 1000);
}
}
}
// ── Echo 级监听器 ──
/**
* 注册全员清屏监听(ScreenCleared)。
*/
function setupScreenClearedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupScreenClearedListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen("ScreenCleared", (e) => {
const operator = e.operator;
const safeOperator = escapeHtml(String(operator || ""));
const say1 = document.getElementById("chat-messages-container");
if (say1) say1.innerHTML = "";
const say2 = document.getElementById("chat-messages-container2");
if (say2) {
const items = say2.querySelectorAll(".msg-line");
items.forEach((item) => {
if (!item.querySelector(".msg-secret")) {
item.remove();
}
});
}
const state = getState();
if (state) {
state.lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null;
}
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
}
/**
* 注册房间级"刷新全员"监听(BrowserRefreshRequested)。
*/
function setupRoomBrowserRefreshListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRoomBrowserRefreshListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen("BrowserRefreshRequested", (e) => {
window.dispatchEvent(
new CustomEvent("chat:browser-refresh-requested", { detail: e })
);
});
}
/**
* 注册开发日志发布通知监听(仅 Room 1)。
*/
function setupChangelogPublishedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupChangelogPublishedListener, 500);
return;
}
if (window.chatContext.roomId !== 1) return;
window.Echo.join("room.1")
.listen(".ChangelogPublished", (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ""));
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ""));
const changelogRoute = window.chatContext?.changelogUrl || "/changelog";
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, changelogRoute));
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
sysDiv.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;";
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
📋 【版本更新】v${safeVersion} · ${safeTitle}
<a href="${safeUrl}" target="_blank" rel="noopener"
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
查看详情 →
</a>
</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
}
/**
* 注册五子棋 PvP 邀请通知监听。
*/
function setupGomokuInviteListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupGomokuInviteListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen(".gomoku.invite", (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const isSelf = (e.inviter_name === window.chatContext.username);
const div = document.createElement("div");
div.className = "msg-line";
div.style.cssText =
"background:linear-gradient(135deg,#e8eef8,#f0f4fc); border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;";
const safeInviterName = escapeHtml(e.inviter_name);
const gomokuGameId = Number.parseInt(e.game_id, 10) || 0;
const acceptBtn = isSelf
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
cursor:pointer; font-family:inherit; transition:all .15s;">
⤴️ 打开面板
</button>`
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
border-radius:12px; background:#336699; color:#fff; font-size:12px;
cursor:pointer; font-family:inherit; transition:opacity .15s;">
⚔️ 接受挑战
</button>`;
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
♟️ 【五子棋】<b>${safeInviterName}</b> 发起了随机对战!${isSelf ? "(等待中)" : ""}
</span>${acceptBtn}
<span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(div);
say1.scrollTop = say1.scrollHeight;
}
if (!isSelf) {
setTimeout(() => {
const btn = document.getElementById(`gomoku-accept-${e.game_id}`);
if (btn) {
btn.textContent = "已超时";
btn.disabled = true;
btn.style.opacity = ".5";
btn.style.cursor = "not-allowed";
}
}, 60000);
}
})
.listen(".gomoku.finished", (e) => {
if (e.mode !== "pvp") return;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const div = document.createElement("div");
div.className = "msg-line";
div.style.cssText =
"background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;";
const reason = { win: "获胜", draw: "平局", resign: "认输", timeout: "超时" }[e.reason] || "结束";
let text = "";
if (e.winner === 0) {
text = `♟️ 五子棋对局以<b>平局</b>结束!`;
} else {
text = `♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
}
div.innerHTML = `<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(div);
say1.scrollTop = say1.scrollHeight;
}
});
}
// ── 主事件绑定 ──
/**
* 绑定所有聊天室 WebSocket 事件监听,仅执行一次。
*/
export function bindChatEvents() {
if (chatEventsBound || typeof document === "undefined") {
return;
}
chatEventsBound = true;
// WebSocket 初始化
document.addEventListener("DOMContentLoaded", initChatWebSocket);
// chat:here — Presence 初始用户列表
window.addEventListener("chat:here", (e) => {
const state = getState();
if (!state) return;
const users = e.detail;
state.onlineUsers = {};
users.forEach((u) => {
window.hydrateOnlineUserPayload(u.username, u);
});
// 注入 AI 小班长
if (window.chatContext?.chatBotEnabled && window.chatContext.botUser) {
window.hydrateOnlineUserPayload("AI小班长", window.chatContext.botUser);
}
// 同步当前用户状态
if (typeof window.setOnlineUserDailyStatus === "function" && typeof window.getCurrentUserDailyStatus === "function") {
window.setOnlineUserDailyStatus(window.chatContext?.username, window.getCurrentUserDailyStatus());
}
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
window.scheduleRenderUserList(0);
});
// chat:bot-toggled — AI 小班长动态开关
window.addEventListener("chat:bot-toggled", (e) => {
const detail = e.detail;
if (window.chatContext) {
window.chatContext.chatBotEnabled = detail.isOnline;
}
if (detail.isOnline && detail.user && detail.user.username) {
window.hydrateOnlineUserPayload(detail.user.username, detail.user);
if (window.chatContext) window.chatContext.botUser = detail.user;
} else {
const state = getState();
if (state) delete state.onlineUsers["AI小班长"];
if (window.chatContext) window.chatContext.botUser = null;
}
window.scheduleRenderUserList?.();
});
// chat:user-status-updated — 用户每日状态更新
window.addEventListener("chat:user-status-updated", (e) => {
const username = e.detail?.username;
const payload = e.detail?.user;
if (!username || !payload) return;
window.hydrateOnlineUserPayload(username, payload);
if (username === window.chatContext?.username) {
if (window.chatContext) {
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
}
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
}
window.scheduleRenderUserList?.();
});
// chat:joining — 用户进入
window.addEventListener("chat:joining", (e) => {
const user = e.detail;
window.hydrateOnlineUserPayload(user.username, user);
window.scheduleRenderUserList?.();
});
// chat:leaving — 用户离开
window.addEventListener("chat:leaving", (e) => {
const user = e.detail;
const state = getState();
if (state) delete state.onlineUsers[user.username];
window.scheduleRenderUserList?.();
});
// chat:message — 新消息
window.addEventListener("chat:message", (e) => {
const msg = e.detail;
if (msg.is_secret && msg.from_user !== window.chatContext?.username && msg.to_user !== window.chatContext?.username) {
return;
}
enqueueChatMessage(msg);
if (msg.action === "vip_presence" && typeof window.showVipPresenceBanner === "function") {
window.showVipPresenceBanner(msg);
}
// 若消息携带 toast_notification 字段且当前用户是接收者,弹右下角小卡片
if (msg.toast_notification && msg.to_user === window.chatContext?.username) {
const t = msg.toast_notification;
window.chatToast?.show({
title: t.title || "通知",
message: t.message || "",
icon: t.icon || "💬",
color: t.color || "#336699",
duration: t.duration ?? 8000,
});
}
});
// chat:kicked — 被踢出房间
window.addEventListener("chat:kicked", (e) => {
if (e.detail.username === window.chatContext?.username) {
const roomsIndexUrl = window.chatContext?.roomsIndexUrl || "/rooms";
window.chatDialog?.alert(
"您已被管理员踢出房间!" + (e.detail.reason ? " 原因:" + e.detail.reason : ""),
"系统通知",
"#cc4444"
);
window.location.href = roomsIndexUrl;
}
});
// chat:muted — 禁言事件
window.addEventListener("chat:muted", handleMutedEvent);
// chat:title-updated — 房间标题更新
window.addEventListener("chat:title-updated", (e) => {
const display = document.getElementById("room-title-display");
if (display) display.innerText = e.detail.title;
});
// chat:browser-refresh-requested — 全员刷新通知
window.addEventListener("chat:browser-refresh-requested", (e) => {
const detail = e.detail || {};
const operatorName = escapeHtml(String(detail.operator || "站长"));
const reasonText = escapeHtml(String(detail.reason || "页面功能已更新,请重新载入。"));
window.chatToast?.show({
title: "页面即将刷新",
message: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
icon: "♻️",
color: "#0f766e",
duration: 2200,
});
window.setTimeout(() => {
window.location.reload();
}, 900);
});
// chat:user-browser-refresh-requested — 目标用户定向刷新
window.addEventListener("chat:user-browser-refresh-requested", (e) => {
const detail = e.detail || {};
const operatorName = escapeHtml(String(detail.operator || "管理员"));
const reasonText = escapeHtml(String(detail.reason || "你的权限状态已发生变化,页面即将刷新。"));
window.chatToast?.show({
title: "权限同步中",
message: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
icon: "🔄",
color: "#7c3aed",
duration: 2600,
});
window.setTimeout(() => {
window.location.reload();
}, 1000);
});
// chat:effect — 全屏特效事件
window.addEventListener("chat:effect", (e) => {
const type = e.detail?.type;
const target = e.detail?.target_username;
const operator = e.detail?.operator;
const myName = window.chatContext?.username;
if (type && typeof EffectManager !== "undefined") {
if (!target || target === myName || operator === myName) {
EffectManager.play(type);
}
}
});
// Echo 级监听器(延迟绑定,等待 Echo 就绪)
document.addEventListener("DOMContentLoaded", () => {
setupScreenClearedListener();
setupRoomBrowserRefreshListener();
setupChangelogPublishedListener();
setupGomokuInviteListener();
});
}
export { initChatWebSocket };
+223
View File
@@ -0,0 +1,223 @@
// 聊天室共享运行时状态,桥接 Blade 闭包作用域与 Vite 模块。
// 所有需要跨模块共享的可变状态集中在此管理,通过 window.chatState 访问。
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
export const PUBLIC_MESSAGE_NODE_LIMIT = 600;
export const PRIVATE_MESSAGE_NODE_LIMIT = 300;
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
export const HEARTBEAT_INTERVAL = 60 * 1000;
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "系统", "欢迎", "系统播报", "神秘箱子"];
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说"
export const ACTION_TEXT_MAP = {
"微笑": { type: "emotion", word: "微笑着" },
"大笑": { type: "emotion", word: "大笑着" },
"愤怒": { type: "emotion", word: "愤怒地" },
"哭泣": { type: "emotion", word: "哭泣着" },
"害羞": { type: "emotion", word: "害羞地" },
"鄙视": { type: "emotion", word: "鄙视地" },
"得意": { type: "emotion", word: "得意地" },
"疑惑": { type: "emotion", word: "疑惑地" },
"同情": { type: "emotion", word: "同情地" },
"无奈": { type: "emotion", word: "无奈地" },
"拳打": { type: "verb", word: "拳打了" },
"飞吻": { type: "verb", word: "飞吻了" },
"偷看": { type: "verb", word: "偷看了" },
};
// ── DOM 引用(惰性获取,避免模块加载时 DOM 未就绪)──
function getContainer() { return document.getElementById("chat-messages-container"); }
function getContainer2() { return document.getElementById("chat-messages-container2"); }
function getUserList() { return document.getElementById("online-users-list"); }
function getToUserSelect() { return document.getElementById("to_user"); }
function getOnlineCount() { return document.getElementById("online-count"); }
function getOnlineCountBottom() { return document.getElementById("online-count-bottom"); }
// ── 可变状态 ──
let onlineUsers = {};
let autoScroll = true;
let maxMsgId = 0;
let pendingChatMessages = [];
let chatMessageFlushTimer = null;
let userListRenderTimer = null;
let userFilterRenderTimer = null;
let userBadgeRotationTick = 0;
let lastAutosaveNode = null;
let roomsRefreshTimer = null;
let roomsOnlineStatusCache = null;
let roomsOnlineStatusCacheAt = 0;
let isMutedUntil = 0;
let imeComposing = false;
let isSending = false;
let sendStartedAt = 0;
let leaveRequestInFlight = false;
let heartbeatFailCount = 0;
const MAX_HEARTBEAT_FAILS = 3;
// 偏好状态
let blockedSystemSenders = new Set();
let initialChatPreferences = null;
// ── 访问器 ──
function getOnlineUsers() { return onlineUsers; }
function setOnlineUsers(v) {
onlineUsers = v;
window.onlineUsers = onlineUsers;
}
function getAutoScroll() { return autoScroll; }
function setAutoScroll(v) { autoScroll = Boolean(v); }
function getMaxMsgId() { return maxMsgId; }
function setMaxMsgId(v) { if (v > maxMsgId) maxMsgId = v; }
function getBlockedSystemSenders() { return blockedSystemSenders; }
function setBlockedSystemSenders(v) { blockedSystemSenders = v; }
function getIsMutedUntil() { return isMutedUntil; }
function setIsMutedUntil(v) { isMutedUntil = v; }
// ── 构建聊天状态对象 ──
const chatState = {
// 常量
BLOCKABLE_SYSTEM_SENDERS,
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
CHAT_SOUND_MUTED_STORAGE_KEY,
PUBLIC_MESSAGE_NODE_LIMIT,
PRIVATE_MESSAGE_NODE_LIMIT,
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
ROOMS_ONLINE_STATUS_CACHE_TTL,
HEARTBEAT_INTERVAL,
SYSTEM_USERS,
ACTION_TEXT_MAP,
MAX_HEARTBEAT_FAILS,
// DOM 引用
get container() { return getContainer(); },
get container2() { return getContainer2(); },
get userList() { return getUserList(); },
get toUserSelect() { return getToUserSelect(); },
get onlineCount() { return getOnlineCount(); },
get onlineCountBottom() { return getOnlineCountBottom(); },
// 在线用户
get onlineUsers() { return onlineUsers; },
set onlineUsers(v) {
onlineUsers = v;
window.onlineUsers = onlineUsers;
},
// 自动滚屏
get autoScroll() { return autoScroll; },
set autoScroll(v) { autoScroll = Boolean(v); },
// 最大消息 ID
get maxMsgId() { return maxMsgId; },
set maxMsgId(v) { maxMsgId = v; },
trackMaxMsgId(v) { if (v > maxMsgId) maxMsgId = v; },
// 消息队列
get pendingChatMessages() { return pendingChatMessages; },
set pendingChatMessages(v) { pendingChatMessages = v; },
get chatMessageFlushTimer() { return chatMessageFlushTimer; },
set chatMessageFlushTimer(v) { chatMessageFlushTimer = v; },
// 用户列表渲染
get userListRenderTimer() { return userListRenderTimer; },
set userListRenderTimer(v) { userListRenderTimer = v; },
get userFilterRenderTimer() { return userFilterRenderTimer; },
set userFilterRenderTimer(v) { userFilterRenderTimer = v; },
get userBadgeRotationTick() { return userBadgeRotationTick; },
set userBadgeRotationTick(v) { userBadgeRotationTick = v; },
// 存点节点
get lastAutosaveNode() { return lastAutosaveNode; },
set lastAutosaveNode(v) { lastAutosaveNode = v; },
// 房间在线状态缓存
get roomsRefreshTimer() { return roomsRefreshTimer; },
set roomsRefreshTimer(v) { roomsRefreshTimer = v; },
get roomsOnlineStatusCache() { return roomsOnlineStatusCache; },
set roomsOnlineStatusCache(v) { roomsOnlineStatusCache = v; },
get roomsOnlineStatusCacheAt() { return roomsOnlineStatusCacheAt; },
set roomsOnlineStatusCacheAt(v) { roomsOnlineStatusCacheAt = v; },
// 禁言
get isMutedUntil() { return isMutedUntil; },
set isMutedUntil(v) { isMutedUntil = v; },
// 发送锁
get imeComposing() { return imeComposing; },
set imeComposing(v) { imeComposing = v; },
get isSending() { return isSending; },
set isSending(v) { isSending = v; },
get sendStartedAt() { return sendStartedAt; },
set sendStartedAt(v) { sendStartedAt = v; },
// 退出房间
get leaveRequestInFlight() { return leaveRequestInFlight; },
set leaveRequestInFlight(v) { leaveRequestInFlight = v; },
// 心跳计数
get heartbeatFailCount() { return heartbeatFailCount; },
set heartbeatFailCount(v) { heartbeatFailCount = v; },
// 偏好
get blockedSystemSenders() { return blockedSystemSenders; },
set blockedSystemSenders(v) { blockedSystemSenders = v; },
get initialChatPreferences() { return initialChatPreferences; },
set initialChatPreferences(v) { initialChatPreferences = v; },
// 重置所有状态(用于测试或强制同步)
reset() {
onlineUsers = {};
window.onlineUsers = onlineUsers;
autoScroll = true;
maxMsgId = 0;
pendingChatMessages = [];
chatMessageFlushTimer = null;
userListRenderTimer = null;
userFilterRenderTimer = null;
userBadgeRotationTick = 0;
lastAutosaveNode = null;
roomsRefreshTimer = null;
roomsOnlineStatusCache = null;
roomsOnlineStatusCacheAt = 0;
isMutedUntil = 0;
imeComposing = false;
isSending = false;
sendStartedAt = 0;
leaveRequestInFlight = false;
heartbeatFailCount = 0;
blockedSystemSenders = new Set();
},
};
// 挂载到 window 供 Blade 脚本及其他模块使用
window.chatState = chatState;
// 向后兼容 Blade 中已暴露的 window 接口
export function hydrateOnlineUserPayload(username, payload) {
const nextPayload = { ...(onlineUsers[username] || {}) };
// 清除旧状态字段
delete nextPayload.daily_status_key;
delete nextPayload.daily_status_label;
delete nextPayload.daily_status_icon;
delete nextPayload.daily_status_group;
delete nextPayload.daily_status_expires_at;
onlineUsers[username] = { ...nextPayload, ...payload };
window.onlineUsers = onlineUsers;
}
window.hydrateOnlineUserPayload = hydrateOnlineUserPayload;
export function renderUserList() {
// 占位:实际渲染逻辑由 user-list.js 挂载到 window.chatState.renderUserList
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
}
export { onlineUsers, autoScroll, maxMsgId };
+332 -14
View File
@@ -1,40 +1,347 @@
// 聊天输入区事件绑定,逐步替代底部输入栏内联提交事件
// 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
let chatComposerEventsBound = false;
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
function getState() {
return window.chatState;
}
function getDraftStorageKey() {
return `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`;
}
// ── 草稿管理 ──
function persistChatDraft(value = null) {
try {
const contentInput = document.getElementById("content");
const draft = value ?? contentInput?.value ?? "";
if (draft === "") {
sessionStorage.removeItem(getDraftStorageKey());
return;
}
sessionStorage.setItem(getDraftStorageKey(), draft);
} catch (_) {
// 会话存储不可用时静默降级
}
}
function loadChatDraft() {
try {
return sessionStorage.getItem(getDraftStorageKey()) || "";
} catch (_) {
return "";
}
}
// ── 消息发送 ──
/**
* 绑定聊天表单提交事件
* 发送主流程仍由 Blade 主脚本的 sendMessage 维护,这里只统一 submit 入口。
*
* @returns {void}
* 将当前输入区状态整理为一份稳定快照
*/
function collectChatComposerState() {
const contentInput = document.getElementById("content");
const submitBtn = document.getElementById("send-btn");
const imageInput = document.getElementById("chat_image");
const toUserSelect = document.getElementById("to_user");
const actionSelect = document.getElementById("action");
const fontColorInput = document.getElementById("font_color");
const secretCheckbox = document.getElementById("is_secret");
const contentRaw = contentInput?.value ?? "";
const selectedImage = imageInput?.files?.[0] ?? null;
return {
contentInput,
submitBtn,
imageInput,
contentRaw,
content: contentRaw.trim(),
selectedImage,
toUser: toUserSelect?.value || "大家",
action: actionSelect?.value || "",
fontColor: fontColorInput?.value || "",
isSecret: Boolean(secretCheckbox?.checked),
};
}
/**
* 基于当前聊天快照构造稳定的 multipart 请求体。
*/
function buildChatMessageFormData(composerState) {
const formData = new FormData();
formData.append("content", composerState.contentRaw);
formData.append("to_user", composerState.toUser);
formData.append("action", composerState.action);
formData.append("font_color", composerState.fontColor);
if (composerState.isSecret) {
formData.append("is_secret", "1");
}
if (composerState.selectedImage) {
formData.append("image", composerState.selectedImage);
}
return formData;
}
/**
* 处理聊天图片选择后的前端状态展示。
*/
function handleChatImageSelected(input) {
const file = input?.files?.[0] ?? null;
if (!file) return;
// 用户选择图片后,立即触发自动发送
sendMessage(null);
}
/**
* 清理当前选中的聊天图片。
*/
function clearSelectedChatImage(resetInput = false) {
const imageInput = document.getElementById("chat_image");
if (resetInput && imageInput) {
imageInput.value = "";
}
}
/**
* 页面从后台恢复后,同步草稿、图片提示和发送锁状态。
*/
function syncChatComposerAfterResume() {
const state = getState();
const contentInput = document.getElementById("content");
if (!contentInput) return;
const savedDraft = loadChatDraft();
if (contentInput.value === "" && savedDraft !== "") {
contentInput.value = savedDraft;
} else if (contentInput.value !== "") {
persistChatDraft(contentInput.value);
}
const imageInput = document.getElementById("chat_image");
if (!imageInput?.files?.length) {
clearSelectedChatImage();
}
if (state) {
state.imeComposing = false;
}
if (state && state.isSending && Date.now() - state.sendStartedAt > 15000) {
const submitBtn = document.getElementById("send-btn");
if (submitBtn) submitBtn.disabled = false;
state.isSending = false;
state.sendStartedAt = 0;
}
}
/**
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)。
*/
async function sendMessage(e) {
if (e) e.preventDefault();
const state = getState();
if (state?.isSending) return;
if (state) {
state.isSending = true;
state.sendStartedAt = Date.now();
}
// 前端禁言检查
if (state && state.isMutedUntil > Date.now()) {
const remaining = Math.ceil((state.isMutedUntil - Date.now()) / 1000);
const remainMin = Math.ceil(remaining / 60);
const muteDiv = document.createElement("div");
muteDiv.className = "msg-line";
muteDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">【提示】您正在禁言中,还需等待约 ${remainMin} 分钟(${remaining} 秒)后方可发言。</span>`;
const say2 = document.getElementById("say2");
if (say2) {
say2.appendChild(muteDiv);
say2.scrollTop = say2.scrollHeight;
}
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
return;
}
const composerState = collectChatComposerState();
const { contentInput, submitBtn, content, contentRaw, selectedImage, toUser } = composerState;
if (!content && !selectedImage) {
contentInput?.focus();
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
return;
}
// AI 小助手私聊转发
if (toUser === "AI小班长" && content && typeof window.sendToChatBot === "function") {
window.sendToChatBot(content, composerState.isSecret);
}
// ── 神秘箱子暗号拦截 ──
const passcodePattern = /^[A-Z0-9]{4,8}$/;
if (!selectedImage && passcodePattern.test(content.trim())) {
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
try {
const claimRes = await fetch("/mystery-box/claim", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ passcode: content.trim() }),
});
const claimData = await claimRes.json();
if (claimData.ok) {
contentInput.value = "";
persistChatDraft("");
contentInput.focus();
window._mysteryBoxActive = false;
window._mysteryBoxPasscode = null;
const isPositive = (claimData.reward ?? 1) >= 0;
window.chatDialog?.alert(
claimData.message || "开箱成功!",
isPositive ? "🎉 恭喜!" : "☠️ 中了陷阱!",
isPositive ? "#10b981" : "#ef4444"
);
if (window.__chatUser && claimData.balance !== undefined) {
window.__chatUser.jjb = claimData.balance;
}
return;
}
} catch (_) {
// 网络错误时静默回退正常发送
}
}
submitBtn.disabled = true;
const formData = buildChatMessageFormData({ ...composerState, contentRaw });
try {
const response = await fetch(window.chatContext.sendUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
body: formData,
});
const data = await response.json();
if (response.ok && data.status === "success") {
contentInput.value = "";
persistChatDraft("");
clearSelectedChatImage(true);
contentInput.focus();
} else {
window.chatDialog?.alert(
"发送失败: " + (data.message || JSON.stringify(data.errors)),
"操作失败",
"#cc4444"
);
}
} catch (error) {
window.chatDialog?.alert("网络连接错误,消息发送失败!", "网络错误", "#cc4444");
console.error(error);
} finally {
submitBtn.disabled = false;
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
}
}
// ── 事件绑定 ──
/**
* 绑定聊天输入区的所有事件:submit、IME、keydown、草稿、焦点恢复。
*/
export function bindChatComposerControls() {
if (chatComposerEventsBound || typeof document === "undefined") {
return;
}
chatComposerEventsBound = true;
// 表单提交
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.matches("[data-chat-form]")) {
return;
}
event.preventDefault();
if (typeof window.sendMessage === "function") {
void window.sendMessage(event);
}
});
// 输入框事件绑定
const contentInput = document.getElementById("content");
if (contentInput) {
contentInput.addEventListener("input", function () {
persistChatDraft(this.value);
});
// IME 组词开始
contentInput.addEventListener("compositionstart", () => {
const state = getState();
if (state) state.imeComposing = true;
});
// IME 组词结束
contentInput.addEventListener("compositionend", () => {
setTimeout(() => {
const state = getState();
if (state) state.imeComposing = false;
}, 10);
});
// Enter 发送
contentInput.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const state = getState();
if (state?.imeComposing) return;
sendMessage(e);
}
});
}
// 页面恢复事件
syncChatComposerAfterResume();
window.addEventListener("pageshow", syncChatComposerAfterResume);
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible") {
syncChatComposerAfterResume();
}
});
window.addEventListener("focus", function () {
setTimeout(syncChatComposerAfterResume, 0);
});
}
/**
* 设置聊天动作并把焦点带回输入框。
* 该入口兼容旧模板可能存在的 `setAction(...)` 调用,切换右侧标签仍交给 Blade 里的 switchTab 处理。
*
* @param {string} action 动作名称
* @param {(tab:string) => void} switchTabHandler 右侧标签切换函数
* @returns {void}
*/
export function setChatComposerAction(
action,
@@ -47,7 +354,6 @@ export function setChatComposerAction(
actionSelect.value = action;
}
// 右侧在线名单切换仍在 Blade 主脚本内,模块只通过兼容入口调用。
if (typeof switchTabHandler === "function") {
switchTabHandler("users");
}
@@ -56,3 +362,15 @@ export function setChatComposerAction(
contentInput.focus();
}
}
// ── 挂载到 window ──
window.sendMessage = sendMessage;
window.handleChatImageSelected = handleChatImageSelected;
window.collectChatComposerState = collectChatComposerState;
window.buildChatMessageFormData = buildChatMessageFormData;
window.clearSelectedChatImage = clearSelectedChatImage;
window.persistChatDraft = persistChatDraft;
window.loadChatDraft = loadChatDraft;
window.syncChatComposerAfterResume = syncChatComposerAfterResume;
export { sendMessage, handleChatImageSelected, clearSelectedChatImage, collectChatComposerState, buildChatMessageFormData };
+479 -41
View File
@@ -1,93 +1,531 @@
// 每日签到弹窗事件代理,先迁移按钮与遮罩事件,签到业务仍由 Blade 主脚本维护
// 每日签到完整模块:事件代理、API 请求与日历渲染全部由 Vite 管理
import { escapeHtml } from "./html.js";
let dailySignInEventsBound = false;
/**
* 调用每日签到存量全局函数。
*
* @param {string} functionName 全局函数名
* @param {...unknown} args 参数
* @returns {void}
*/
function callDailySignInGlobal(functionName, ...args) {
// 当前模块只负责 data-* 事件到旧函数的桥接,接口请求和日历渲染暂不迁移。
if (typeof window[functionName] === "function") {
window[functionName](...args);
}
// ── 状态(全局共享,兼容 Blade 中 window.dailySignInState 引用)──
window.dailySignInState = window.dailySignInState || {
month: null,
prevMonth: null,
nextMonth: null,
repairCardItem: null,
repairCardCount: 0,
rewardRules: [],
status: null,
};
// ── 辅助函数 ──────────────────────────────────────────
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 读取每日签到月份翻页目标
*
* @param {"prev"|"next"|string} direction 翻页方向
* @returns {string|null}
* 从服务端响应中提取最新金币余额
*/
function resolveDailySignInMonth(direction) {
if (direction === "prev") {
return window.dailySignInState?.prevMonth || null;
}
function resolveDailySignInGoldBalance(data) {
const candidates = [
data?.data?.user?.jjb,
data?.data?.user?.gold,
data?.data?.presence?.jjb,
data?.data?.presence?.gold,
data?.data?.my_jjb,
data?.data?.new_jjb,
data?.data?.balance,
data?.my_jjb,
data?.new_jjb,
data?.balance,
];
if (direction === "next") {
return window.dailySignInState?.nextMonth || null;
for (const candidate of candidates) {
const amount = Number(candidate);
if (Number.isFinite(amount)) return amount;
}
return null;
}
/**
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件
*
* @returns {void}
* 从签到响应中提取当前用户最新在线载荷
*/
export function bindDailySignInControls() {
if (dailySignInEventsBound || typeof document === "undefined") {
function resolveDailySignInPresencePayload(data) {
const candidates = [
data?.data?.presence,
data?.data?.online_user,
data?.data?.onlineUser,
data?.data?.user_payload,
data?.data?.userPayload,
data?.data?.user,
data?.presence,
data?.online_user,
data?.onlineUser,
];
return candidates.find(p => p && typeof p === 'object') || null;
}
/**
* 从签到响应中提取签到身份字段。
*/
function resolveDailySignInIdentityPayload(data) {
const identity = data?.data?.identity || data?.data?.sign_identity || data?.identity || data?.sign_identity;
if (!identity || typeof identity !== 'object') return {};
return {
sign_identity_key: identity.key ?? identity.sign_identity_key ?? identity.code ?? '',
sign_identity_label: identity.label ?? identity.name ?? identity.sign_identity_label ?? '',
sign_identity_icon: identity.icon ?? identity.sign_identity_icon ?? '',
sign_identity_color: identity.color ?? identity.sign_identity_color ?? undefined,
sign_identity_bg_color: identity.bg_color ?? identity.background_color ?? identity.sign_identity_bg_color ?? undefined,
sign_identity_border_color: identity.border_color ?? identity.sign_identity_border_color ?? undefined,
};
}
/**
* 将签到成功结果同步到金币余额与在线名单。
*/
function applyDailySignInResult(data) {
const balance = resolveDailySignInGoldBalance(data);
const payload = resolveDailySignInPresencePayload(data);
const identityPayload = resolveDailySignInIdentityPayload(data);
const username = window.chatContext?.username;
if (balance !== null && window.chatContext) {
window.chatContext.userJjb = balance;
window.chatContext.myGold = balance;
}
if (username) {
// hydrateOnlineUserPayload 由 Blade 主脚本暴露在 window 上供 Vite 模块桥接调用。
if (typeof window.hydrateOnlineUserPayload === "function") {
window.hydrateOnlineUserPayload(username, {
...(payload || {}),
...identityPayload,
username,
});
}
}
// 通知 Blade 主脚本刷新在线用户列表。
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
}
// ── 渲染函数 ──────────────────────────────────────────
function getState() {
return window.dailySignInState;
}
function renderDailySignInStatus() {
const status = getState().status || {};
const streakEl = document.getElementById('daily-sign-streak');
const previewEl = document.getElementById('daily-sign-preview');
const cardCountEl = document.getElementById('daily-sign-card-count');
const cardPriceEl = document.getElementById('daily-sign-card-price');
const claimBtn = document.getElementById('daily-sign-claim-btn');
const buyBtn = document.getElementById('daily-sign-buy-card-btn');
const cardItem = getState().repairCardItem;
if (streakEl) streakEl.textContent = `连续 ${Number(status.current_streak_days || 0)}`;
if (previewEl) {
const rule = status.preview_rule || {};
const parts = [];
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward} 金币`);
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward} 经验`);
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward} 魅力`);
previewEl.textContent = status.signed_today ? '今日已签到' : `今日可领:${parts.join(' + ') || '签到奖励'}`;
}
if (cardCountEl) cardCountEl.textContent = `补签卡 ${getState().repairCardCount || 0}`;
if (cardPriceEl) {
cardPriceEl.textContent = cardItem
? `${cardItem.icon || '🗓️'} ${cardItem.name}${Number(cardItem.price || 0).toLocaleString()} 金币`
: '补签卡暂未上架';
}
if (claimBtn) {
claimBtn.disabled = !!status.signed_today;
claimBtn.textContent = status.signed_today ? '今日已签到' : '今日签到';
claimBtn.style.opacity = status.signed_today ? '0.55' : '1';
claimBtn.style.cursor = status.signed_today ? 'not-allowed' : 'pointer';
}
if (buyBtn) {
buyBtn.disabled = !cardItem?.id;
buyBtn.style.opacity = cardItem?.id ? '1' : '0.55';
buyBtn.style.cursor = cardItem?.id ? 'pointer' : 'not-allowed';
}
}
function renderDailySignInCalendar(payload) {
const grid = document.getElementById('daily-sign-calendar-grid');
const label = document.getElementById('daily-sign-month-label');
if (!grid) return;
if (label) label.textContent = payload.month_label || payload.month || '本月';
const days = Array.isArray(payload.days) ? payload.days : [];
grid.innerHTML = '';
const firstWeekday = Number(days[0]?.weekday || 0);
for (let i = 0; i < firstWeekday; i += 1) {
const blank = document.createElement('div');
blank.className = 'daily-sign-day blank';
grid.appendChild(blank);
}
days.forEach(day => {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'daily-sign-day';
if (day.signed) cell.classList.add('signed');
if (day.can_makeup) cell.classList.add('missed');
if (day.is_today) cell.classList.add('today');
if (day.is_future) cell.classList.add('future');
const stateText = day.signed
? `${day.is_makeup ? '补签' : '已签'} ${day.streak_days || ''}`
: (day.is_future ? '未到' : (day.is_today ? '今天' : '漏签'));
cell.innerHTML = `<span class="day-num">${day.day}</span><span class="day-state">${escapeHtml(stateText)}</span>`;
cell.title = day.reward_text || stateText;
if (day.can_makeup) cell.dataset.dailySignMakeup = day.date;
grid.appendChild(cell);
});
}
function renderDailySignInRewardRules() {
const list = document.getElementById('daily-sign-rewards-list');
const progress = document.getElementById('daily-sign-reward-progress');
if (!list) return;
const currentDays = Number(getState().status?.current_streak_days || 0);
const rules = getState().rewardRules || [];
if (progress) progress.textContent = `当前 ${currentDays}`;
if (!rules.length) {
list.innerHTML = '<div style="font-size:12px;color:#94a3b8;padding:4px;">暂无奖励规则</div>';
return;
}
dailySignInEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
list.innerHTML = rules.map(rule => {
const streakDays = Number(rule.streak_days || 0);
const parts = [];
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward}`);
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward}经验`);
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward}魅力`);
const icon = escapeHtml(rule.identity_badge_icon || '✅');
const name = escapeHtml(rule.identity_badge_name || '签到奖励');
const color = escapeHtml(rule.identity_badge_color || '#0f766e');
const activeClass = currentDays >= streakDays ? ' active' : '';
const distanceText = currentDays >= streakDays ? '已达成' : `还差 ${Math.max(streakDays - currentDays, 0)}`;
const rewardText = escapeHtml(parts.join(' + ') || '签到记录');
return `
<div class="daily-sign-reward-card${activeClass}" title="${name} · ${rewardText} · ${escapeHtml(distanceText)}">
<div class="daily-sign-reward-title">
<span>第 ${streakDays} 天</span>
<span style="color:${color};">${icon}</span>
</div>
<div class="daily-sign-reward-name">${name}</div>
<div class="daily-sign-reward-desc">${rewardText}</div>
</div>
`;
}).join('');
}
// ── API 请求函数 ──────────────────────────────────────
async function loadDailySignInStatus() {
const statusUrl = window.chatContext?.dailySignInStatusUrl;
if (!statusUrl) return;
const response = await fetch(statusUrl, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf() },
});
const data = await response.json();
if (!response.ok || data?.status === 'error') {
throw new Error(data?.message || '签到状态加载失败');
}
getState().status = data.data || {};
renderDailySignInStatus();
}
async function loadDailySignInCalendar(month) {
const calendarUrl = window.chatContext?.dailySignInCalendarUrl;
if (!calendarUrl) return;
const url = new URL(calendarUrl, window.location.origin);
if (month) url.searchParams.set('month', month);
const response = await fetch(url.toString(), {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf() },
});
const data = await response.json();
if (!response.ok || data?.status === 'error') {
throw new Error(data?.message || '签到日历加载失败');
}
const payload = data.data || {};
const state = getState();
state.month = payload.month || month || null;
state.prevMonth = payload.prev_month || null;
state.nextMonth = payload.next_month || null;
state.repairCardItem = payload.sign_repair_card_item || null;
state.repairCardCount = Number(payload.makeup_card_count || 0);
state.rewardRules = Array.isArray(payload.reward_rules) ? payload.reward_rules : [];
renderDailySignInCalendar(payload);
renderDailySignInStatus();
renderDailySignInRewardRules();
}
// ── 公开操作 ──────────────────────────────────────────
async function openDailySignInModal() {
const modal = document.getElementById('daily-sign-modal');
if (!window.chatContext?.dailySignInCalendarUrl || !modal) {
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
return;
}
modal.style.display = 'flex';
await Promise.all([
loadDailySignInStatus(),
loadDailySignInCalendar(getState().month),
]);
}
function closeDailySignInModal() {
const modal = document.getElementById('daily-sign-modal');
if (modal) modal.style.display = 'none';
}
async function quickDailySignIn() {
await openDailySignInModal();
}
async function claimDailySignInFromModal() {
const claimUrl = window.chatContext?.dailySignInClaimUrl;
if (!claimUrl) {
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
return;
}
try {
const response = await fetch(claimUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrf(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ room_id: window.chatContext?.roomId ?? null }),
});
const data = await response.json();
if (!response.ok || data?.status === 'error' || data?.ok === false) {
throw new Error(data?.message || '签到失败');
}
applyDailySignInResult(data);
await Promise.all([
loadDailySignInStatus(),
loadDailySignInCalendar(getState().month),
]);
renderDailySignInRewardRules();
window.chatToast?.show({
title: '签到成功',
message: data?.message || '今日签到奖励已到账。',
icon: data?.data?.sign_identity_icon || data?.data?.identity?.icon || '✅',
color: '#16a34a',
duration: 3200,
});
} catch (error) {
window.chatDialog?.alert(error.message || '签到失败,请稍后重试。', '签到失败', '#cc4444');
}
}
async function makeupDailySignIn(targetDate) {
const makeupUrl = window.chatContext?.dailySignInMakeupUrl;
if (!makeupUrl) return;
const ok = await window.chatDialog?.confirm(`确认使用 1 张补签卡补签 ${targetDate} 吗?`, '确认补签');
if (!ok) return;
try {
const response = await fetch(makeupUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrf(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ target_date: targetDate, room_id: window.chatContext?.roomId ?? null }),
});
const data = await response.json();
if (!response.ok || data?.status === 'error' || data?.ok === false) {
const firstError = data?.errors ? Object.values(data.errors).flat()[0] : null;
throw new Error(firstError || data?.message || '补签失败');
}
applyDailySignInResult(data);
await Promise.all([
loadDailySignInStatus(),
loadDailySignInCalendar(getState().month),
]);
renderDailySignInRewardRules();
window.chatToast?.show({
title: '补签成功',
message: data?.message || '补签已完成。',
icon: '🗓️',
color: '#0f766e',
duration: 3200,
});
} catch (error) {
window.chatDialog?.alert(error.message || '补签失败,请稍后重试。', '补签失败', '#cc4444');
}
}
async function promptSignRepairQuantity(item) {
const unitPrice = Number(item?.price || 0);
const ruleText = item?.description || '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。';
const promptPromise = window.chatDialog?.prompt(
`请输入要购买的补签卡数量(1-99):\n单价 ${unitPrice.toLocaleString()} 金币\n说明:${ruleText}`,
'1',
'购买补签卡',
'#0f766e',
);
const inputEl = document.getElementById('global-dialog-input');
const previousInputStyle = inputEl?.getAttribute('style') || '';
if (inputEl) {
inputEl.style.minHeight = '40px';
inputEl.style.height = '40px';
inputEl.style.resize = 'none';
inputEl.style.overflow = 'hidden';
}
const rawQuantity = await promptPromise;
if (inputEl) inputEl.setAttribute('style', previousInputStyle);
if (rawQuantity === null || rawQuantity === undefined) return null;
const quantity = Number.parseInt(String(rawQuantity).trim(), 10);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
window.chatDialog?.alert('购买数量必须是 1 到 99 之间的整数。', '数量不正确', '#cc4444');
return null;
}
return quantity;
}
async function buyDailySignRepairCard() {
const item = getState().repairCardItem;
if (!item?.id) {
window.chatDialog?.alert('补签卡暂未上架。', '提示', '#f59e0b');
return;
}
const quantity = await promptSignRepairQuantity(item);
if (quantity === null) return;
const totalPrice = Number(item.price || 0) * quantity;
const ok = await window.chatDialog?.confirm(
`确认花费 ${totalPrice.toLocaleString()} 金币购买【${item.name}】× ${quantity} 吗?\n说明:补签卡只能补签本月未签到日期。`,
'购买补签卡',
);
if (!ok) return;
if (typeof window.buyItem === 'function') {
window.buyItem(item.id, item.name, item.price, 'all', '', quantity);
setTimeout(() => {
loadDailySignInCalendar(getState().month);
loadDailySignInStatus();
}, 900);
return;
}
window.openShopModal?.();
}
// ── 暴露到 window(兼容 Blade 存量引用)───────────────
window.openDailySignInModal = openDailySignInModal;
window.closeDailySignInModal = closeDailySignInModal;
window.quickDailySignIn = quickDailySignIn;
window.loadDailySignInCalendar = loadDailySignInCalendar;
window.claimDailySignInFromModal = claimDailySignInFromModal;
window.makeupDailySignIn = makeupDailySignIn;
window.promptSignRepairQuantity = promptSignRepairQuantity;
window.buyDailySignRepairCard = buyDailySignRepairCard;
// ── 事件绑定 ──────────────────────────────────────────
/**
* 读取每日签到月份翻页目标。
*/
function resolveDailySignInMonth(direction) {
if (direction === "prev") return getState().prevMonth || null;
if (direction === "next") return getState().nextMonth || null;
return null;
}
/**
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件。
*/
export function bindDailySignInControls() {
if (dailySignInEventsBound || typeof document === "undefined") return;
dailySignInEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const overlay = event.target.closest("[data-daily-sign-modal-overlay]");
// 只在点击遮罩本身时关闭,避免点击弹窗内容区误触关闭。
if (overlay && event.target === overlay) {
callDailySignInGlobal("closeDailySignInModal");
closeDailySignInModal();
return;
}
if (event.target.closest("[data-daily-sign-close]")) {
event.preventDefault();
callDailySignInGlobal("closeDailySignInModal");
closeDailySignInModal();
return;
}
if (event.target.closest("[data-daily-sign-claim]")) {
event.preventDefault();
callDailySignInGlobal("claimDailySignInFromModal");
claimDailySignInFromModal();
return;
}
if (event.target.closest("[data-daily-sign-buy-repair-card]")) {
event.preventDefault();
callDailySignInGlobal("buyDailySignRepairCard");
buyDailySignRepairCard();
return;
}
const makeupButton = event.target.closest("[data-daily-sign-makeup]");
if (makeupButton) {
event.preventDefault();
// 日历格子由 Blade 主脚本动态生成,这里只读取日期并转发补签旧函数。
callDailySignInGlobal("makeupDailySignIn", makeupButton.getAttribute("data-daily-sign-makeup") || "");
makeupDailySignIn(makeupButton.getAttribute("data-daily-sign-makeup") || "");
return;
}
const monthButton = event.target.closest("[data-daily-sign-month]");
if (monthButton) {
event.preventDefault();
// 月份状态仍由 window.dailySignInState 维护,模块只读取方向并转发旧加载函数。
callDailySignInGlobal("loadDailySignInCalendar", resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
loadDailySignInCalendar(resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
}
});
}
+238
View File
@@ -0,0 +1,238 @@
/**
* 聊天室心跳与存点模块:定时存点、手动存点、退出房间、掉线检测。
* 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
*/
import { escapeHtml } from "./html.js";
import { pruneMessageContainer } from "./message-renderer.js";
const MAX_HEARTBEAT_FAILS = 3;
const HEARTBEAT_INTERVAL = 60 * 1000;
let heartbeatInterval = null;
let heartbeatFailCount = 0;
let leaveRequestInFlight = false;
/**
* 获取 CSRF Token。
*
* @returns {string}
*/
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 获取共享状态对象。
*
* @returns {Object|null}
*/
function getState() {
return window.chatState;
}
// ── 存点功能(手动 + 自动)─────────────────────
/**
* 执行一次存点请求,向服务端同步在线状态并获取经验/金币。
*
* @param {boolean} silent 静默模式(true=仅心跳,不显示存点提示)
* @returns {Promise<void>}
*/
async function saveExp(silent = false) {
if (!window.chatContext?.heartbeatUrl) return;
const state = getState();
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
});
// 检测登录态失效
if (response.status === 401 || response.status === 419) {
await notifyExpiredLeave();
window.chatDialog?.alert(
"⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。",
"连接警告",
"#b45309"
);
window.location.href = "/";
return;
}
const data = await response.json();
if (response.ok && data.status === "success") {
heartbeatFailCount = 0;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const d = data.data;
const identitySummary = d.identity_summary ? `${d.identity_summary} · ` : "";
let levelInfo = "";
if (d.is_max_level) {
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb} · 已满级 ✓`;
} else {
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb}`;
}
// 本次获得的奖励提示
let gainInfo = "";
if (d.exp_gain > 0 || d.jjb_gain > 0) {
const parts = [];
if (d.exp_gain > 0) parts.push(`经验+${d.exp_gain}`);
if (d.jjb_gain > 0) parts.push(`金币+${d.jjb_gain}`);
gainInfo = ` 本次获得:${parts.join("")}`;
}
// 升级通知
if (data.data.leveled_up) {
const upDiv = document.createElement("div");
upDiv.className = "msg-line";
upDiv.innerHTML = `<span style="color: #d97706; font-weight: bold;">【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟</span><span class="msg-time">(${timeStr})</span>`;
const container2 = state?.container2;
if (container2) {
container2.appendChild(upDiv);
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
}
}
// 存点消息输出到包厢窗口
if (!silent) {
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
if (container2) {
const detailDiv = document.createElement("div");
detailDiv.className = "msg-line";
detailDiv.dataset.autosave = "1";
detailDiv.innerHTML = `<span style="color: green;">${escapeHtml(levelInfo + gainInfo)}</span><span class="msg-time">(${timeStr})</span>`;
// 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知
if (state) {
state.lastAutosaveNode?.remove();
state.lastAutosaveNode = detailDiv;
}
container2.appendChild(detailDiv);
pruneMessageContainer(container2, window.chatState?.PRIVATE_MESSAGE_NODE_LIMIT || 300);
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
}
}
}
} catch (e) {
console.error("存点失败", e);
heartbeatFailCount++;
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
window.chatDialog?.alert(
"⚠️ 与服务器的连接已断开,请检查网络后重新登录。",
"连接警告",
"#b45309"
);
window.location.href = "/";
return;
}
if (!silent) {
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
if (container2) {
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
sysDiv.innerHTML = '<span style="color: red;">【系统】存点失败,请稍后重试</span>';
container2.appendChild(sysDiv);
}
}
}
}
// ── 退出房间 ──
/**
* 主动退出房间并关闭页面。
*
* @returns {Promise<void>}
*/
async function leaveRoom() {
if (leaveRequestInFlight) return;
leaveRequestInFlight = true;
try {
await fetch(window.chatContext.leaveUrl + "?explicit=1", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
});
} catch (e) {
console.error(e);
}
// 弹出窗口直接关闭,如果不是弹出窗口则跳回首页
window.close();
setTimeout(() => {
window.location.href = "/";
}, 500);
}
/**
* 通知服务端登录已过期(用于 401/419 响应时)。
*
* @returns {Promise<void>}
*/
async function notifyExpiredLeave() {
if (leaveRequestInFlight) return;
leaveRequestInFlight = true;
try {
if (!window.chatContext?.expiredLeaveUrl) return;
await fetch(window.chatContext.expiredLeaveUrl, {
method: "GET",
headers: { "Accept": "application/json" },
credentials: "same-origin",
});
} catch (e) {
console.error(e);
}
}
// ── 定时器管理 ──
/**
* 启动心跳定时器(每 60 秒自动存点)。
*/
export function startHeartbeat() {
stopHeartbeat(); // 防止重复启动
// 首次心跳延迟 10 秒,让 WebSocket 先连接
const initialTimer = window.setTimeout(() => saveExp(true), 10000);
const intervalTimer = window.setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
heartbeatInterval = { initial: initialTimer, interval: intervalTimer };
}
/**
* 停止心跳定时器。
*/
export function stopHeartbeat() {
if (heartbeatInterval) {
window.clearTimeout(heartbeatInterval.initial);
window.clearInterval(heartbeatInterval.interval);
heartbeatInterval = null;
}
}
// ── 挂载到 window ──
window.saveExp = saveExp;
window.leaveRoom = leaveRoom;
window.notifyExpiredLeave = notifyExpiredLeave;
window.startHeartbeat = startHeartbeat;
window.stopHeartbeat = stopHeartbeat;
export { saveExp, leaveRoom, notifyExpiredLeave, HEARTBEAT_INTERVAL, MAX_HEARTBEAT_FAILS };
+473
View File
@@ -0,0 +1,473 @@
// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { isExpiredChatImageMessage } from "./message-utils.js";
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
import { escapePresenceText } from "./vip-presence.js";
import {
BLOCKABLE_SYSTEM_SENDERS,
PUBLIC_MESSAGE_NODE_LIMIT,
PRIVATE_MESSAGE_NODE_LIMIT,
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
SYSTEM_USERS,
ACTION_TEXT_MAP,
} from "./chat-state.js";
// ── 游戏标签判断 ──
const GAME_LABEL_PREFIXES = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"];
function isGameLabel(name) {
if (GAME_LABEL_PREFIXES.some((p) => name.startsWith(p))) return true;
if (name.includes(" ")) return true;
return false;
}
// ── 构建自然语序的动作串 ──
function buildActionStr(action, fromHtml, toHtml, verb = "说") {
const info = ACTION_TEXT_MAP[action];
if (!info) return `${fromHtml}${toHtml}${escapeHtml(String(action || ""))}${verb}`;
if (info.type === "emotion") return `${fromHtml}${info.word}${toHtml}${verb}`;
return `${fromHtml}${info.word}${toHtml}${verb}`;
}
// ── 可点击用户名 ──
function clickableUser(uName, color, extraClass = "") {
const safeName = escapeHtml(uName);
if (uName === "AI小班长") {
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
}
if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) {
return `<span class="msg-user${extraClass}" style="color: ${color};">${safeName}</span>`;
}
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
}
// ── 解析内容中【用户名】为可点击标记 ──
function parseBracketUsers(content, color = "#000099") {
return content.replace(/【([^】]+)】/g, (_match, uName) => {
return "【" + clickableUser(uName, color) + "】";
});
}
/**
* 构建聊天消息的内容 HTML。
*/
export function buildChatMessageContent(msg, fontColor, textColorClass) {
const rawContent = msg.content || "";
if (msg.message_type === "image" && !isExpiredChatImageMessage(msg)) {
const fullUrl = escapeHtml(msg.image_url || "");
const thumbUrl = escapeHtml(msg.image_thumb_url || "");
const imageName = escapeHtml(msg.image_original_name || "聊天图片");
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; max-width:220px; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
: "";
return `
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}" data-chat-image-lightbox-open
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
<img src="${thumbUrl}" alt="${imageName}"
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
</a>
${captionHtml}
</span>
`;
}
if (msg.message_type === "expired_image" || isExpiredChatImageMessage(msg)) {
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
: "";
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
}
return rawContent;
}
/**
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)。
*
* @param {Object} msg 消息对象
* @param {Object|null} renderBatch 批量渲染上下文
*/
export function appendMessage(msg, renderBatch = null) {
const state = window.chatState;
if (!state) return;
state.trackMaxMsgId(msg.id || 0);
const isMe = msg.from_user === window.chatContext?.username;
const fontColor = msg.font_color || "#000000";
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
const shouldHideByBlock = blockRuleKey ? state.blockedSystemSenders.has(blockRuleKey) : false;
const div = document.createElement("div");
div.className = "msg-line";
if (msg?.from_user) {
div.dataset.fromUser = msg.from_user;
}
if (blockRuleKey) {
div.dataset.blockKey = blockRuleKey;
}
// ── 消息气泡装扮 ──
if (msg.msg_bubble) {
const bubbleStyle = msg.msg_bubble.replace(/^msg_bubble_/, "");
div.classList.add("msg-bubble--" + bubbleStyle);
}
const timeStr = msg.sent_at || "";
let timeStrOverride = false;
let nameClass = "";
if (msg.msg_name_color) {
nameClass = " msg-name--" + msg.msg_name_color.replace(/^msg_name_/, "");
}
let textColorClass = "";
if (msg.msg_text_color) {
textColorClass = " msg-text--" + msg.msg_text_color.replace(/^msg_text_/, "");
}
// 用户头像
const senderInfo = state.onlineUsers[msg.from_user];
const senderHead = (senderInfo && senderInfo.headface) || "1.gif";
let headImgSrc = senderHead.startsWith("storage/") ? "/" + senderHead : `/images/headface/${senderHead}`;
if (msg.from_user.endsWith("播报") || msg.from_user === "星海小博士" || msg.from_user === "系统传音" || msg.from_user === "系统公告") {
headImgSrc = "/images/bugle.png";
}
// ── 头像框装扮 ──
let avatarFrameClass = null;
const avatarFrameRaw = msg.avatar_frame || (senderInfo && senderInfo.avatar_frame);
if (avatarFrameRaw) {
avatarFrameClass = "avatar-frame--" + avatarFrameRaw.replace(/^avatar_frame_/, "");
}
let headImg = "";
if (avatarFrameClass) {
headImg = '<span class="avatar-frame-wrapper-sm">' +
'<span class="avatar-frame ' + avatarFrameClass + '"></span>' +
'<img src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
'</span>';
} else {
headImg = '<img src="' + headImgSrc + '" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src=\'/images/headface/1.gif\'">';
}
const messageBodyHtml = buildChatMessageContent(msg, fontColor, textColorClass);
let html = "";
// ── 消息路由 ──
if (msg.action === "system_welcome") {
div.style.cssText = "margin: 3px 0;";
const iconImg = `<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
const parsedContent = parseBracketUsers(msg.content);
html = `${iconImg} ${parsedContent}`;
} else if (msg.action === "vip_presence") {
const accent = msg.presence_color || "#f59e0b";
div.style.cssText =
`background: linear-gradient(135deg, #ffffff, ${accent}08); border: 2px solid ${accent}44; border-radius: 16px; padding: 12px 16px; margin: 8px 0; box-shadow: 0 4px 15px ${accent}15; position: relative; overflow: hidden;`;
const icon = escapeHtml(msg.presence_icon || "👑");
const levelName = escapeHtml(msg.presence_level_name || "尊贵会员");
const typeLabel = msg.presence_type === "leave"
? "华丽离场"
: (msg.presence_type === "purchase" ? "荣耀开通" : "荣耀入场");
const safeText = escapePresenceText(msg.presence_text || "");
html = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
</div>
`;
timeStrOverride = true;
} else if (msg.action === "欢迎") {
div.style.cssText =
"background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);";
const parsedContent = parseBracketUsers(msg.content, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${parsedContent} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (SYSTEM_USERS.includes(msg.from_user)) {
if (msg.from_user === "系统公告") {
div.style.cssText =
"background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);";
const parsedContent = parseBracketUsers(msg.content, "#dc2626");
html = `<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (msg.from_user === "系统传音") {
const content = msg.content || "";
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
const isPlainNotification =
content.includes("【百家乐】") ||
content.includes("【赛马】") ||
content.includes("神秘箱子") ||
content.includes("【双色球") ||
content.includes("【五子棋】") ||
content.includes("【老虎机】") ||
content.includes("购买了");
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (isPlainNotification) {
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`;
} else {
div.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;";
let sysTranContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
}
} else if (msg.from_user === "系统" && msg.to_user && msg.to_user !== "大家") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;";
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
let giftHtml = "";
if (msg.gift_image) {
giftHtml = `<img src="${msg.gift_image}" alt="${msg.gift_name || ""}" style="display:inline-block;width:40px;height:40px;vertical-align:middle;margin-left:6px;animation:giftBounce 0.6s ease-in-out;">`;
}
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
}
} else if (msg.is_secret) {
if (msg.from_user === "系统") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;";
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
const fromHtml = clickableUser(msg.from_user, "#cc00cc", nameClass);
const toHtml = clickableUser(msg.to_user, "#cc00cc");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml, "悄悄说") :
`${fromHtml}${toHtml}悄悄说:`;
html = `${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
}
} else if (msg.to_user && msg.to_user !== "大家") {
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
const toHtml = clickableUser(msg.to_user, "#000099");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml) :
`${fromHtml}${toHtml}说:`;
html = `${headImg}${verbStr}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
} else {
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, "大家") :
`${fromHtml}对大家说:`;
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;
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
if (shouldHideByBlock) {
div.dataset.blockHidden = "1";
div.style.display = "none";
}
// 后端下发的带有 welcome_user 的系统欢迎/离开消息,替换同类旧消息
if (msg.welcome_user) {
const welcomeKind = msg.welcome_kind || "entry_broadcast";
div.setAttribute("data-system-user", msg.welcome_user);
div.setAttribute("data-system-welcome-kind", welcomeKind);
const removeSameWelcome = (root) => {
root?.querySelectorAll("[data-system-user]").forEach((el) => {
if (el.dataset.systemUser === msg.welcome_user && (el.dataset.systemWelcomeKind || "entry_broadcast") === welcomeKind) {
el.remove();
}
});
};
removeSameWelcome(state.container);
removeSameWelcome(renderBatch?.publicFragment);
removeSameWelcome(renderBatch?.privateFragment);
}
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username;
// 存点通知标记
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;
return;
}
const container2 = state.container2;
if (container2) {
container2.appendChild(div);
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
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,
shouldScrollPublic: false,
shouldScrollPrivate: false,
};
}
/**
* 提交批量渲染的聊天消息,并把裁剪和滚动合并到每批一次。
*/
export function commitChatMessageRenderBatch(renderBatch) {
const state = window.chatState;
if (!state) return;
const hasPublicMessages = renderBatch.publicFragment.childNodes.length > 0;
const hasPrivateMessages = renderBatch.privateFragment.childNodes.length > 0;
if (hasPublicMessages) {
const container = state.container;
if (container) container.appendChild(renderBatch.publicFragment);
}
if (hasPrivateMessages) {
const container2 = state.container2;
if (container2) container2.appendChild(renderBatch.privateFragment);
}
if (renderBatch.shouldPrunePublic) {
const container = state.container;
if (container) pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldPrunePrivate) {
const container2 = state.container2;
if (container2) pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
}
if (renderBatch.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);
}
/**
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
*/
export function flushQueuedChatMessages() {
const state = window.chatState;
if (!state) return;
state.chatMessageFlushTimer = null;
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 };
@@ -307,6 +307,9 @@ export function handleFeatureLocalClear(onLocalClear) {
if (typeof onLocalClear === "function") {
onLocalClear();
} else if (typeof window.localClearScreen === "function") {
// 默认调用聊天室清屏函数,将当前可见消息全部移除。
window.localClearScreen();
}
}
@@ -463,3 +466,390 @@ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlocke
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted);
}
/**
* 根据消息内容识别其对应的屏蔽规则键。
*
* @param {Record<string, unknown>} msg 消息对象
* @returns {string|null}
*/
export function resolveBlockedSystemSenderKey(msg) {
const fromUser = String(msg?.from_user || "");
const content = String(msg?.content || "");
if (fromUser === "钓鱼播报") {
return "钓鱼播报";
}
if (fromUser === "神秘箱子") {
return "神秘箱子";
}
if (fromUser === "星海小博士") {
return "星海小博士";
}
// 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以"系统传音"发送,但正文里带有"钓鱼播报"字样。
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("钓鱼播报") || content.includes("自动钓鱼模式"))) {
return "钓鱼播报";
}
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("神秘箱子")) {
return "神秘箱子";
}
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("百家乐")) {
return "百家乐";
}
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("赛马") || content.includes("跑马"))) {
return "跑马";
}
return null;
}
// ── 偏好持久化 ──
/**
* 构建当前聊天室偏好快照。
*
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
*/
export function buildChatPreferencesPayload() {
const state = window.chatState;
return {
blocked_system_senders: state ? Array.from(state.blockedSystemSenders) : [],
sound_muted: isSoundMuted(),
};
}
/**
* 将聊天室偏好写入本地缓存,供刷新前快速恢复与迁移兜底。
*/
export function persistChatPreferencesToLocal() {
const state = window.chatState;
if (state) {
persistBlockedSystemSenders(state.blockedSystemSenders);
}
setSoundMuted(isSoundMuted());
}
/**
* 将当前聊天室偏好保存到当前登录账号。
*/
export async function saveChatPreferences() {
const payload = buildChatPreferencesPayload();
persistChatPreferencesToLocal();
if (!window.chatContext?.chatPreferencesUrl) {
return;
}
try {
const response = await fetch(window.chatContext.chatPreferencesUrl, {
method: "PUT",
headers: {
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]')?.content ?? "",
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("save chat preferences failed");
}
const data = await response.json();
if (data?.status === "success") {
window.chatContext.chatPreferences = normalizeChatPreferences(data.data || payload);
}
} catch (error) {
console.error("聊天室偏好保存失败:", error);
}
}
// ── 屏蔽 UI 同步 ──
/**
* 同步屏蔽菜单中的复选框状态。
*/
export function syncBlockedSystemSenderCheckboxes() {
const state = window.chatState;
const blockedSet = state ? state.blockedSystemSenders : new Set();
const checkboxMap = {
"block-sender-fishing": "钓鱼播报",
"block-sender-doctor": "星海小博士",
"block-sender-baccarat": "百家乐",
"block-sender-horse-race": "跑马",
"block-sender-mystery-box": "神秘箱子",
};
Object.entries(checkboxMap).forEach(([id, sender]) => {
const checkbox = document.getElementById(id);
if (checkbox) {
checkbox.checked = blockedSet.has(sender);
}
});
}
/**
* 批量切换当前已渲染消息的显示状态。
*
* @param {string} blockKey 屏蔽规则键
* @param {boolean} hidden true = 隐藏,false = 恢复显示
*/
export function setRenderedMessagesVisibilityBySender(blockKey, hidden) {
const state = window.chatState;
[state?.container, state?.container2].forEach(targetContainer => {
if (!targetContainer) return;
targetContainer.querySelectorAll("[data-block-key]").forEach(node => {
if (node.dataset.blockKey === blockKey) {
if (hidden) {
node.dataset.blockHidden = "1";
node.style.display = "none";
} else if (node.dataset.blockHidden === "1") {
node.removeAttribute("data-block-hidden");
node.style.display = "";
}
}
});
});
if (!hidden && state?.autoScroll) {
const container = state.container;
const container2 = state.container2;
if (container) container.scrollTop = container.scrollHeight;
if (container2) container2.scrollTop = container2.scrollHeight;
}
}
/**
* 更新指定系统播报项的屏蔽状态,并在勾选后立即清理当前窗口。
*
* @param {string} sender 系统播报发送者/规则键
* @param {boolean} blocked 是否屏蔽
*/
export function toggleBlockedSystemSender(sender, blocked) {
const state = window.chatState;
if (!state) return;
if (!BLOCKABLE_SYSTEM_SENDERS.includes(sender)) return;
if (blocked) {
state.blockedSystemSenders.add(sender);
setRenderedMessagesVisibilityBySender(sender, true);
} else {
state.blockedSystemSenders.delete(sender);
setRenderedMessagesVisibilityBySender(sender, false);
}
persistBlockedSystemSenders(state.blockedSystemSenders);
syncBlockedSystemSenderCheckboxes();
void saveChatPreferences();
}
// ── 挂载到 window:偏好持久化 ──
window.saveChatPreferences = saveChatPreferences;
window.syncBlockedSystemSenderCheckboxes = syncBlockedSystemSenderCheckboxes;
window.setRenderedMessagesVisibilityBySender = setRenderedMessagesVisibilityBySender;
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
window.persistChatPreferencesToLocal = persistChatPreferencesToLocal;
window.buildChatPreferencesPayload = buildChatPreferencesPayload;
// ── 挂载到 window:菜单/浮层控制(供 bindBlockMenuControls 事件代理调用)──
window.toggleBlockMenu = toggleBlockMenu;
window.toggleFeatureMenu = toggleFeatureMenu;
window.closeFeatureMenu = closeFeatureMenu;
window.openDailyStatusEditor = openDailyStatusEditor;
window.closeDailyStatusEditor = closeDailyStatusEditor;
window.handleFeatureLocalClear = handleFeatureLocalClear;
// ── 每日状态 UI 同步 ──
/**
* 获取当前登录用户仍然有效的每日状态。
*
* @returns {Object|null}
*/
export function getCurrentUserDailyStatus() {
return normalizeDailyStatus(window.chatContext?.currentDailyStatus);
}
/**
* 清除用户在线载荷中的状态字段,避免合并时残留旧状态。
*
* @param {Record<string, unknown>} payload 用户在线载荷
*/
export function removeDailyStatusFields(payload) {
if (!payload || typeof payload !== "object") return;
delete payload.daily_status_key;
delete payload.daily_status_label;
delete payload.daily_status_icon;
delete payload.daily_status_group;
delete payload.daily_status_expires_at;
}
/**
* 将状态写回指定用户的在线载荷。
*
* @param {string} username 用户名
* @param {Object|null} status 标准化后的状态对象
*/
export function setOnlineUserDailyStatus(username, status) {
const onlineUsers = window.chatState?.onlineUsers || window.onlineUsers || {};
if (!username || !onlineUsers[username]) return;
removeDailyStatusFields(onlineUsers[username]);
if (!status) return;
onlineUsers[username].daily_status_key = status.key;
onlineUsers[username].daily_status_label = status.label;
onlineUsers[username].daily_status_icon = status.icon;
onlineUsers[username].daily_status_group = status.group;
onlineUsers[username].daily_status_expires_at = status.expires_at;
}
/**
* 同步状态按钮文字与图标。
*/
function syncDailyStatusTrigger() {
const shortcutIcon = document.getElementById("daily-status-shortcut-icon");
const shortcutLabel = document.getElementById("daily-status-shortcut-label");
const activeStatus = getCurrentUserDailyStatus();
if (shortcutIcon) shortcutIcon.textContent = activeStatus?.icon || "🙂";
if (shortcutLabel) shortcutLabel.textContent = activeStatus?.label || "状态";
}
/**
* 同步状态面板中当前选中项的高亮样式。
*/
function syncDailyStatusMenuSelection() {
const activeKey = getCurrentUserDailyStatus()?.key || "";
document.querySelectorAll("#daily-status-editor-overlay .daily-status-item").forEach((button) => {
const selected = button.dataset.statusKey === activeKey;
button.style.borderColor = selected ? "#6366f1" : "#e5e7eb";
button.style.background = selected ? "linear-gradient(180deg,#eef2ff 0%,#e0e7ff 100%)" : "#ffffffcc";
button.style.color = selected ? "#312e81" : "#334155";
button.style.boxShadow = selected ? "0 8px 18px rgba(99,102,241,.18)" : "none";
button.style.transform = selected ? "translateY(-1px)" : "translateY(0)";
});
}
/**
* 同步聊天室状态相关 UI(按钮、面板高亮、聊天上下文)。
*/
export function syncDailyStatusUi() {
const activeStatus = getCurrentUserDailyStatus();
if (window.chatContext) window.chatContext.currentDailyStatus = activeStatus;
syncDailyStatusTrigger();
syncDailyStatusMenuSelection();
}
// ── 每日状态更新与清除 ──
/**
* 向服务端发送每日状态更新请求。
*
* @param {string} statusKey 状态键值
* @returns {Promise<void>}
*/
export async function updateDailyStatus(statusKey) {
const url = window.chatContext?.dailyStatusUpdateUrl;
if (!url || !statusKey) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
try {
const response = await fetch(url, {
method: "PUT",
headers: {
"X-CSRF-TOKEN": csrf,
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ daily_status_key: statusKey }),
});
if (!response.ok) throw new Error("update daily status failed");
const data = await response.json();
if (data?.status === "success" && window.chatContext) {
window.chatContext.currentDailyStatus = data.data ?? null;
}
closeDailyStatusEditor();
syncDailyStatusUi();
// 让在线用户列表同步当前用户的最新状态
const username = window.chatContext?.username;
if (username) {
setOnlineUserDailyStatus(username, getCurrentUserDailyStatus());
}
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
} catch (error) {
console.error("每日状态更新失败:", error);
}
}
/**
* 清除当前登录用户的每日状态。
*
* @returns {Promise<void>}
*/
export async function clearDailyStatus() {
const url = window.chatContext?.dailyStatusUpdateUrl;
if (!url) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
try {
const response = await fetch(url, {
method: "PUT",
headers: {
"X-CSRF-TOKEN": csrf,
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ daily_status_key: null }),
});
if (!response.ok) throw new Error("clear daily status failed");
const data = await response.json();
if (data?.status === "success" && window.chatContext) {
window.chatContext.currentDailyStatus = null;
}
closeDailyStatusEditor();
syncDailyStatusUi();
// 移除当前用户在线载荷中的状态字段
const username = window.chatContext?.username;
if (username) {
setOnlineUserDailyStatus(username, null);
}
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
} catch (error) {
console.error("每日状态清除失败:", error);
}
}
// ── 挂载到 window:每日状态 ──
window.getCurrentUserDailyStatus = getCurrentUserDailyStatus;
window.setOnlineUserDailyStatus = setOnlineUserDailyStatus;
window.syncDailyStatusUi = syncDailyStatusUi;
window.updateDailyStatus = updateDailyStatus;
window.clearDailyStatus = clearDailyStatus;
+3
View File
@@ -59,6 +59,9 @@ function confirmToolbarLeaveRoom() {
*
* @returns {void}
*/
// ── 挂载到 window ──
window.runFeatureShortcut = runFeatureShortcut;
export function bindToolbarControls() {
if (toolbarEventsBound || typeof document === "undefined") {
return;
+343
View File
@@ -0,0 +1,343 @@
// 聊天室在线用户列表渲染:名单、搜索、徽标轮换。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml } from "./html.js";
import { normalizeDailyStatus } from "./preferences-status.js";
// ── 每日状态解析 ──
function resolveUserDailyStatus(user) {
return normalizeDailyStatus(user);
}
// ── 构建职务 / 管理员徽标 ──
function buildUserPrimaryBadgeHtml(user, username) {
if (user.position_icon) {
const posTitle = (user.position_name || "在职") + " · " + username;
const safePosTitle = escapeHtml(String(posTitle));
const safePositionIcon = escapeHtml(String(user.position_icon || "🎖️"));
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
}
if (user.is_admin) {
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
}
return "";
}
// ── 构建用户 VIP 徽标 ──
function buildUserVipBadgeHtml(user) {
if (!user.vip_icon) {
return "";
}
const vipColor = user.vip_color || "#f59e0b";
const safeVipTitle = escapeHtml(String(user.vip_name || "VIP"));
const safeVipIcon = escapeHtml(String(user.vip_icon || "👑"));
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
}
// ── 构建状态徽标 ──
function buildUserStatusBadgeHtml(user) {
const status = resolveUserDailyStatus(user);
if (!status) {
return "";
}
const safeIcon = escapeHtml(status.icon);
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
}
// ── 构建签到身份徽标 ──
function buildUserSignIdentityBadgeHtml(user) {
const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? "");
const identityIcon = String(user.sign_identity_icon ?? "");
if (!identityKey || !identityIcon) {
return "";
}
const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? "");
const safeIcon = escapeHtml(identityIcon);
const safeTooltip = escapeHtml(identityLabel ? `签到 · ${identityLabel}` : "签到身份");
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
}
/**
* 按轮换节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。
*/
export function buildUserBadgeHtml(user, username) {
const state = window.chatState;
const tick = state ? state.userBadgeRotationTick : 0;
const badges = [
buildUserSignIdentityBadgeHtml(user),
buildUserStatusBadgeHtml(user),
buildUserPrimaryBadgeHtml(user, username),
buildUserVipBadgeHtml(user),
].filter(Boolean);
if (badges.length === 0) {
return "";
}
return badges[tick % badges.length];
}
/**
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
*/
export function refreshRenderedUserBadges(scope = document) {
const state = window.chatState;
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
scope.querySelectorAll(".user-item[data-username]").forEach((item) => {
const username = item.dataset.username;
const badgeSlot = item.querySelector(".user-badge-slot");
if (!username || !badgeSlot) {
return;
}
badgeSlot.innerHTML = buildUserBadgeHtml(onlineUsers[username] || {}, username);
});
}
/**
* 核心渲染函数:将在线用户渲染到指定容器(桌面端名单区和手机端抽屉共用)。
*/
export function renderUserListToContainer(targetContainer, sortBy, keyword) {
if (!targetContainer) return;
const state = window.chatState;
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
const fragment = document.createDocumentFragment();
// 在列表顶部添加"大家"条目
const allDiv = document.createElement("div");
allDiv.className = "user-item";
allDiv.dataset.userListEveryone = "1";
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
fragment.appendChild(allDiv);
// 构建用户数组并排序
let userArr = [];
for (let username in onlineUsers) {
userArr.push({ username, ...onlineUsers[username] });
}
if (sortBy === "name") {
userArr.sort((a, b) => a.username.localeCompare(b.username, "zh"));
} else if (sortBy === "level") {
userArr.sort((a, b) => (b.user_level || 0) - (a.user_level || 0));
}
userArr.forEach((user) => {
const username = user.username;
// 搜索过滤
if (keyword && !username.toLowerCase().includes(keyword)) return;
const item = document.createElement("div");
item.className = "user-item";
item.dataset.username = username;
item.dataset.userListEntry = "1";
const headface = (user.headface || "1.gif");
const headImgSrc = headface.startsWith("storage/") ? "/" + headface : "/images/headface/" + headface;
const badges = buildUserBadgeHtml(user, username);
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? "color:#e91e8c;" : "";
// 昵称颜色装扮
let userNameExtraClass = "";
if (user.name_color) {
userNameExtraClass = " msg-name--" + user.name_color.replace(/^msg_name_/, "");
}
// 头像框装扮
let avatarHtml = "";
if (user.avatar_frame) {
const frameClass = "avatar-frame--" + user.avatar_frame.replace(/^avatar_frame_/, "");
avatarHtml = '<span class="avatar-frame-wrapper">' +
'<span class="avatar-frame ' + frameClass + '"></span>' +
'<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
'</span>';
} else {
avatarHtml = '<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">';
}
item.innerHTML = `
${avatarHtml}
<span class="user-name${userNameExtraClass}" style="${nameColor}">${username}</span>
<span class="user-badge-slot">${badges}</span>
`;
// 具体点击、双击与手机双触发由 Vite 的 right-panel.js 统一事件委托处理
fragment.appendChild(item);
});
targetContainer.replaceChildren(fragment);
refreshRenderedUserBadges(targetContainer);
}
/**
* 渲染完整用户列表(含下拉选单与在线计数)。
*/
export function renderUserList() {
const state = window.chatState;
if (!state) return;
if (state.userListRenderTimer) {
window.clearTimeout(state.userListRenderTimer);
state.userListRenderTimer = null;
}
const userList = state.userList;
const toUserSelect = state.toUserSelect;
// 获取排序方式和搜索词
const sortSelect = document.getElementById("user-sort-select");
const sortBy = sortSelect ? sortSelect.value : "default";
const searchInput = document.getElementById("user-search-input");
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
// 调用核心渲染
if (userList) {
renderUserListToContainer(userList, sortBy, keyword);
}
// 下拉框重建
if (toUserSelect) {
const selectFragment = document.createDocumentFragment();
const everyoneOption = document.createElement("option");
everyoneOption.value = "大家";
everyoneOption.textContent = "大家";
selectFragment.appendChild(everyoneOption);
for (let username in state.onlineUsers) {
if (username !== window.chatContext?.username) {
const option = document.createElement("option");
option.value = username;
option.textContent = username === "AI小班长" ? "🤖 AI小班长" : username;
selectFragment.appendChild(option);
}
}
toUserSelect.replaceChildren(selectFragment);
}
// 在线计数
const count = Object.keys(state.onlineUsers).length;
const onlineCount = state.onlineCount;
const onlineCountBottom = state.onlineCountBottom;
if (onlineCount) onlineCount.innerText = count;
if (onlineCountBottom) onlineCountBottom.innerText = count;
const footer = document.getElementById("online-count-footer");
if (footer) footer.innerText = count;
// 派发用户列表更新事件,供手机端抽屉同步
window.dispatchEvent(new Event("chatroom:users-updated"));
}
/**
* 合并高频在线名单变动,避免 Presence 连续进出时重复重建名单 DOM。
*/
export function scheduleRenderUserList(delay = 120) {
const state = window.chatState;
if (!state) return;
if (state.userListRenderTimer) {
window.clearTimeout(state.userListRenderTimer);
}
state.userListRenderTimer = window.setTimeout(() => {
state.userListRenderTimer = null;
renderUserList();
}, delay);
}
/**
* 搜索/过滤用户列表(仅操作 DOM 可见性,不重建)。
*/
export function filterUserList() {
const searchInput = document.getElementById("user-search-input");
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
const state = window.chatState;
const userList = state?.userList || document.getElementById("online-users-list");
if (!userList) return;
const items = userList.querySelectorAll(".user-item");
items.forEach((item) => {
if (!keyword) {
item.style.display = "";
return;
}
const name = (item.dataset.username || item.textContent || "").toLowerCase();
item.style.display = name.includes(keyword) ? "" : "none";
});
}
/**
* 调度用户列表搜索过滤,避免每个按键都同步扫描名单 DOM。
*/
export function scheduleFilterUserList() {
const state = window.chatState;
if (!state) return;
if (state.userFilterRenderTimer !== null) {
return;
}
const scheduleFilter = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
state.userFilterRenderTimer = scheduleFilter(() => {
state.userFilterRenderTimer = null;
filterUserList();
});
}
// ── 徽标旋转定时器 ──
let badgeRotationInterval = null;
export function startBadgeRotation() {
if (badgeRotationInterval) return;
badgeRotationInterval = window.setInterval(() => {
if (document.hidden) return;
const state = window.chatState;
if (!state) return;
state.userBadgeRotationTick = (state.userBadgeRotationTick + 1) % 4;
if (state.userList) {
refreshRenderedUserBadges(state.userList);
}
const mobileUsersList = document.getElementById("mob-online-users-list");
if (mobileUsersList?.offsetParent !== null) {
refreshRenderedUserBadges(mobileUsersList);
}
// 同步每日状态 UI
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
}, 3000);
}
export function stopBadgeRotation() {
if (badgeRotationInterval) {
window.clearInterval(badgeRotationInterval);
badgeRotationInterval = null;
}
}
// ── 挂载到 window ──
window.renderUserList = renderUserList;
window.renderUserListToContainer = renderUserListToContainer;
window.filterUserList = filterUserList;
window.scheduleFilterUserList = scheduleFilterUserList;
window.scheduleRenderUserList = scheduleRenderUserList;
window.refreshRenderedUserBadges = refreshRenderedUserBadges;
window.buildUserBadgeHtml = buildUserBadgeHtml;
window._renderUserListToContainer = renderUserListToContainer;
export {
buildUserPrimaryBadgeHtml,
buildUserVipBadgeHtml,
buildUserStatusBadgeHtml,
buildUserSignIdentityBadgeHtml,
resolveUserDailyStatus,
};
+104
View File
@@ -0,0 +1,104 @@
// 会员进退场豪华横幅模块,从 Blade 内联脚本迁移至 Vite 管理。
import { escapeHtml } from "./html.js";
/**
* 转义会员横幅文本,避免横幅层被注入 HTML。
*/
function escapePresenceText(text) {
return escapeHtml(String(text ?? "")).replace(/\n/g, "<br>");
}
/**
* 根据不同的会员横幅风格返回渐变与光影配置。
*/
function getVipPresenceStyleConfig(style, color) {
const fallback = color || "#f59e0b";
const map = {
aurora: {
gradient: `linear-gradient(135deg, #f59e0b, #fbbf24, #fef3c7)`,
glow: `rgba(251, 191, 36, 0.4)`,
accent: "#78350f",
},
storm: {
gradient: `linear-gradient(135deg, #0ea5e9, #7dd3fc, #f0f9ff)`,
glow: `rgba(125, 211, 252, 0.4)`,
accent: "#0369a1",
},
royal: {
gradient: `linear-gradient(135deg, #d97706, #fcd34d, #fffbeb)`,
glow: `rgba(252, 211, 77, 0.4)`,
accent: "#92400e",
},
cosmic: {
gradient: `linear-gradient(135deg, #db2777, #f472b6, #fdf2f8)`,
glow: `rgba(244, 114, 182, 0.4)`,
accent: "#9d174d",
},
farewell: {
gradient: `linear-gradient(135deg, #ea580c, #fb923c, #fff7ed)`,
glow: `rgba(251, 146, 60, 0.4)`,
accent: "#9a3412",
},
};
return map[style] || map.aurora;
}
/**
* 显示会员进退场豪华横幅。
*
* @param {Record<string, any>} payload 携带 presence_text / presence_type / presence_icon 等字段的消息载荷
* @returns {void}
*/
function showVipPresenceBanner(payload) {
if (!payload || !payload.presence_text) {
return;
}
const existing = document.getElementById("vip-presence-banner");
if (existing) {
existing.remove();
}
const styleConfig = getVipPresenceStyleConfig(
payload.presence_banner_style,
payload.presence_color,
);
const bannerTypeLabel =
payload.presence_type === "leave"
? "离场提示"
: payload.presence_type === "purchase"
? "开通喜报"
: "闪耀登场";
const banner = document.createElement("div");
banner.id = "vip-presence-banner";
banner.className = "vip-presence-banner";
banner.innerHTML = `
<div class="vip-presence-banner__glow" style="background:${styleConfig.glow};"></div>
<div class="vip-presence-banner__card" style="background:${styleConfig.gradient}; border-color:${payload.presence_color || "#fff"};">
<div class="vip-presence-banner__meta">
<span class="vip-presence-banner__icon">${escapeHtml(payload.presence_icon || "👑")}</span>
<span class="vip-presence-banner__level">${escapeHtml(payload.presence_level_name || "尊贵会员")}</span>
<span class="vip-presence-banner__type">${bannerTypeLabel}</span>
</div>
<div class="vip-presence-banner__text" style="color:${styleConfig.accent};">
${escapePresenceText(payload.presence_text)}
</div>
</div>
`;
document.body.appendChild(banner);
setTimeout(() => {
banner.classList.add("is-leaving");
setTimeout(() => banner.remove(), 700);
}, 4200);
}
// 挂载到 window 供 Blade 脚本及其他模块使用。
window.showVipPresenceBanner = showVipPresenceBanner;
export { showVipPresenceBanner, getVipPresenceStyleConfig, escapePresenceText };
+30 -13
View File
@@ -2,10 +2,31 @@
let welcomeMenuEventsBound = false;
/**
* 切换欢迎语下拉浮层的显示/隐藏。
*/
function toggleWelcomeMenu(event) {
event.stopPropagation();
const menu = document.getElementById("welcome-menu");
const adminMenu = document.getElementById("admin-menu");
const blockMenu = document.getElementById("block-menu");
const featureMenu = document.getElementById("feature-menu");
const dailyStatusEditor = document.getElementById("daily-status-editor-overlay");
if (!menu) return;
[adminMenu, blockMenu, featureMenu, dailyStatusEditor].forEach((el) => {
if (el) el.style.display = "none";
});
menu.style.display = menu.style.display === "none" ? "block" : "none";
}
// 挂载到 window 供 Blade 脚本及其他模块使用。
window.toggleWelcomeMenu = toggleWelcomeMenu;
/**
* 绑定欢迎语菜单按钮、菜单内点击拦截与模板发送事件。
*
* @returns {void}
*/
export function bindWelcomeMenuControls() {
if (welcomeMenuEventsBound || typeof document === "undefined") {
@@ -19,32 +40,28 @@ export function bindWelcomeMenuControls() {
return;
}
// 欢迎语菜单外部点击关闭仍由主脚本处理,这里只负责菜单按钮与菜单内部。
const toggleButton = event.target.closest("[data-chat-welcome-menu-toggle]");
if (toggleButton) {
event.preventDefault();
window.toggleWelcomeMenu?.(event);
toggleWelcomeMenu(event);
return;
}
const menu = event.target.closest("[data-chat-welcome-menu]");
if (!menu) {
return;
}
if (!menu) return;
// 阻止菜单内部点击冒泡,避免选择模板时被外层关闭逻辑抢先处理
// 阻止菜单内部点击冒泡。
event.stopPropagation();
const item = event.target.closest("[data-chat-welcome-template]");
if (!item || !menu.contains(item)) {
return;
}
if (!item || !menu.contains(item)) return;
const template = item.getAttribute("data-chat-welcome-template") || "";
// 模板内容只从 data 属性读取,实际发送仍交给旧的 sendWelcomeTpl
// sendWelcomeTpl 仍由 Blade 维护(依赖 sendMessage),这里通过 window 调用
if (template && typeof window.sendWelcomeTpl === "function") {
window.sendWelcomeTpl(template);
}
});
}
export { toggleWelcomeMenu };
+3
View File
@@ -117,6 +117,9 @@
'envelopeStatusUrlTemplate' => '/wedding/__ID__/envelope-status',
],
'earnRewardUrl' => route('earn.video_reward'),
'roomsOnlineStatusUrl' => route('chat.rooms-online-status'),
'changelogUrl' => route('changelog.index'),
'roomsIndexUrl' => route('rooms.index'),
'chatImageRetentionDays' => 3,
'initialState' => [
'historyMessages' => $historyMessages ?? [],
File diff suppressed because it is too large Load Diff