diff --git a/resources/css/app.css b/resources/css/app.css
index 32c4a72..c9a129b 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -144,3 +144,5 @@
transform: translateX(140%);
}
}
+
+@import './chat-decorations.css';
diff --git a/resources/css/chat-decorations.css b/resources/css/chat-decorations.css
new file mode 100644
index 0000000..4f0f6b2
--- /dev/null
+++ b/resources/css/chat-decorations.css
@@ -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%);
+ /* 头像置于框下方 */
+}
diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js
index 654c461..4d5be5c 100644
--- a/resources/js/chat-room.js
+++ b/resources/js/chat-room.js
@@ -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();
}
diff --git a/resources/js/chat-room/admin-commands.js b/resources/js/chat-room/admin-commands.js
new file mode 100644
index 0000000..b1ec380
--- /dev/null
+++ b/resources/js/chat-room/admin-commands.js
@@ -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,
+};
diff --git a/resources/js/chat-room/admin-menu.js b/resources/js/chat-room/admin-menu.js
index 70f69c1..933dd5d 100644
--- a/resources/js/chat-room/admin-menu.js
+++ b/resources/js/chat-room/admin-menu.js
@@ -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;
}
diff --git a/resources/js/chat-room/chat-events.js b/resources/js/chat-room/chat-events.js
new file mode 100644
index 0000000..6ce077a
--- /dev/null
+++ b/resources/js/chat-room/chat-events.js
@@ -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 = `【系统】${d.message}(${timeStr})`;
+
+ 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 = '【系统】您的禁言已解除,可以继续发言了。';
+ 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 = `🧹 管理员 ${safeOperator} 已执行全员清屏(${timeStr})`;
+ 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 = `
+ 📋 【版本更新】v${safeVersion} · ${safeTitle}
+
+ 查看详情 →
+
+ (${timeStr})`;
+
+ 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
+ ? ``
+ : ``;
+
+ div.innerHTML = `
+ ♟️ 【五子棋】${safeInviterName} 发起了随机对战!${isSelf ? "(等待中)" : ""}
+ ${acceptBtn}
+ (${timeStr})`;
+
+ 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 = `♟️ 五子棋对局以平局结束!`;
+ } else {
+ text = `♟️ ${e.winner_name} 击败 ${e.loser_name}(${reason})获得 ${e.reward_gold} 金币!`;
+ }
+
+ div.innerHTML = `${text}(${timeStr})`;
+ 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: `${operatorName} 通知全员刷新页面。
${reasonText}`,
+ 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: `${operatorName} 已更新你的职务状态。
${reasonText}`,
+ 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 };
diff --git a/resources/js/chat-room/chat-state.js b/resources/js/chat-room/chat-state.js
new file mode 100644
index 0000000..4e95f40
--- /dev/null
+++ b/resources/js/chat-room/chat-state.js
@@ -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 };
diff --git a/resources/js/chat-room/composer.js b/resources/js/chat-room/composer.js
index d208f70..99c4fc0 100644
--- a/resources/js/chat-room/composer.js
+++ b/resources/js/chat-room/composer.js
@@ -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 = `【提示】您正在禁言中,还需等待约 ${remainMin} 分钟(${remaining} 秒)后方可发言。`;
+ 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 };
diff --git a/resources/js/chat-room/daily-sign-in.js b/resources/js/chat-room/daily-sign-in.js
index 6e534ad..9bdbdba 100644
--- a/resources/js/chat-room/daily-sign-in.js
+++ b/resources/js/chat-room/daily-sign-in.js
@@ -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 = `${day.day}${escapeHtml(stateText)}`;
+ 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 = '
`;
+ 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 = `
+