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 = '
暂无奖励规则
'; 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 ` +
+
+ 第 ${streakDays} 天 + ${icon} +
+
${name}
+
${rewardText}
+
+ `; + }).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") || "")); } }); } diff --git a/resources/js/chat-room/heartbeat.js b/resources/js/chat-room/heartbeat.js new file mode 100644 index 0000000..ec64160 --- /dev/null +++ b/resources/js/chat-room/heartbeat.js @@ -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} + */ +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 = `【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟(${timeStr})`; + 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 = `${escapeHtml(levelInfo + gainInfo)}(${timeStr})`; + + // 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知 + 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 = '【系统】存点失败,请稍后重试'; + container2.appendChild(sysDiv); + } + } + } +} + +// ── 退出房间 ── + +/** + * 主动退出房间并关闭页面。 + * + * @returns {Promise} + */ +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} + */ +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 }; diff --git a/resources/js/chat-room/message-renderer.js b/resources/js/chat-room/message-renderer.js new file mode 100644 index 0000000..b81e73d --- /dev/null +++ b/resources/js/chat-room/message-renderer.js @@ -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 `${safeName}`; + } + if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) { + return `${safeName}`; + } + return `${safeName}`; +} + +// ── 解析内容中【用户名】为可点击标记 ── +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 + ? `${rawContent}` + : ""; + + return ` + + + ${imageName} + + ${captionHtml} + + `; + } + + if (msg.message_type === "expired_image" || isExpiredChatImageMessage(msg)) { + const captionColorStyle = textColorClass ? "" : `color:${fontColor};`; + const captionHtml = rawContent + ? `${rawContent}` + : ""; + + return ` + + 🖼️ 图片已过期 + ${captionHtml} + + `; + } + + 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 = '' + + '' + + '' + + ''; + } else { + headImg = ''; + } + + const messageBodyHtml = buildChatMessageContent(msg, fontColor, textColorClass); + let html = ""; + + // ── 消息路由 ── + if (msg.action === "system_welcome") { + div.style.cssText = "margin: 3px 0;"; + const iconImg = ``; + 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 = ` +
+
${icon}
+
+
+ ${typeLabel} + ${levelName} + (${timeStr}) +
+
${safeText}
+
+
${icon}
+
+ `; + 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 = `
💬 ${parsedContent} (${timeStr})
`; + 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 = `
${parsedContent} (${timeStr})
`; + 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 = `🌟 ${plainAccentContent}`; + } else if (isPlainNotification) { + let parsedContent = parseBracketUsers(msg.content); + html = `${headImg}${clickableUser(msg.from_user, fontColor, nameClass)}:${parsedContent}`; + } 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 = `🌟 ${sysTranContent}`; + } + } 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 = `📢 系统:${msg.content}`; + } else { + let giftHtml = ""; + if (msg.gift_image) { + giftHtml = `${msg.gift_name || `; + } + let parsedContent = parseBracketUsers(msg.content); + html = `${headImg}${clickableUser(msg.from_user, fontColor, nameClass)}:${parsedContent}${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 = `📢 系统:${msg.content}`; + } 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}${verbStr}${messageBodyHtml}`; + } + } 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}${messageBodyHtml}`; + } else { + const fromHtml = clickableUser(msg.from_user, "#000099", nameClass); + const verbStr = msg.action ? + buildActionStr(msg.action, fromHtml, "大家") : + `${fromHtml}对大家说:`; + html = `${headImg}${verbStr}${messageBodyHtml}`; + } + + if (!timeStrOverride) { + html += ` (${timeStr})`; + } + 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 }; diff --git a/resources/js/chat-room/preferences-status.js b/resources/js/chat-room/preferences-status.js index 2a261f5..9866c0c 100644 --- a/resources/js/chat-room/preferences-status.js +++ b/resources/js/chat-room/preferences-status.js @@ -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} 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} 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} + */ +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} + */ +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; diff --git a/resources/js/chat-room/toolbar.js b/resources/js/chat-room/toolbar.js index 57e5a33..d3cdd36 100644 --- a/resources/js/chat-room/toolbar.js +++ b/resources/js/chat-room/toolbar.js @@ -59,6 +59,9 @@ function confirmToolbarLeaveRoom() { * * @returns {void} */ +// ── 挂载到 window ── +window.runFeatureShortcut = runFeatureShortcut; + export function bindToolbarControls() { if (toolbarEventsBound || typeof document === "undefined") { return; diff --git a/resources/js/chat-room/user-list.js b/resources/js/chat-room/user-list.js new file mode 100644 index 0000000..3ebb836 --- /dev/null +++ b/resources/js/chat-room/user-list.js @@ -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 `${safePositionIcon}`; + } + if (user.is_admin) { + return `🎖️`; + } + 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 `${safeVipIcon}`; +} + +// ── 构建状态徽标 ── +function buildUserStatusBadgeHtml(user) { + const status = resolveUserDailyStatus(user); + if (!status) { + return ""; + } + const safeIcon = escapeHtml(status.icon); + const safeTooltip = escapeHtml(`${status.group} · ${status.label}`); + return `${safeIcon}`; +} + +// ── 构建签到身份徽标 ── +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 `${safeIcon}`; +} + +/** + * 按轮换节奏在签到身份、状态、职务/管理、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 = '大家'; + 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 = '' + + '' + + '' + + ''; + } else { + avatarHtml = ''; + } + + item.innerHTML = ` + ${avatarHtml} + ${username} + ${badges} + `; + + // 具体点击、双击与手机双触发由 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, +}; diff --git a/resources/js/chat-room/vip-presence.js b/resources/js/chat-room/vip-presence.js new file mode 100644 index 0000000..831460c --- /dev/null +++ b/resources/js/chat-room/vip-presence.js @@ -0,0 +1,104 @@ +// 会员进退场豪华横幅模块,从 Blade 内联脚本迁移至 Vite 管理。 + +import { escapeHtml } from "./html.js"; + +/** + * 转义会员横幅文本,避免横幅层被注入 HTML。 + */ +function escapePresenceText(text) { + return escapeHtml(String(text ?? "")).replace(/\n/g, "
"); +} + +/** + * 根据不同的会员横幅风格返回渐变与光影配置。 + */ +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} 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 = ` +
+
+
+ ${escapeHtml(payload.presence_icon || "👑")} + ${escapeHtml(payload.presence_level_name || "尊贵会员")} + ${bannerTypeLabel} +
+
+ ${escapePresenceText(payload.presence_text)} +
+
+ `; + + document.body.appendChild(banner); + + setTimeout(() => { + banner.classList.add("is-leaving"); + setTimeout(() => banner.remove(), 700); + }, 4200); +} + +// 挂载到 window 供 Blade 脚本及其他模块使用。 +window.showVipPresenceBanner = showVipPresenceBanner; + +export { showVipPresenceBanner, getVipPresenceStyleConfig, escapePresenceText }; diff --git a/resources/js/chat-room/welcome-menu.js b/resources/js/chat-room/welcome-menu.js index c850c4d..f6ed997 100644 --- a/resources/js/chat-room/welcome-menu.js +++ b/resources/js/chat-room/welcome-menu.js @@ -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 }; diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index baddd87..30d3e98 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -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 ?? [], diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index d930190..e2cd093 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -1,1813 +1,42 @@ {{-- 文件功能:聊天室核心前端交互脚本(Blade 模板形式) - 包含: - 1. 消息渲染与路由(appendMessage) - 2. 在线用户列表管理(renderUserList / filterUserList) - 3. WebSocket 事件监听(chat:here / chat:message / chat:muted 等) - 4. 管理操作(adminClearScreen / promptAnnouncement 等) - 5. 存点心跳(saveExp,60秒自动) - 6. 钓鱼小游戏(startFishing / reelFish / autoFish) - 7. 发送消息(sendMessage,IME 防重触发) - 8. 特效控制(triggerEffect / applyFontSize / toggleSoundMute) - 9. 系统播报屏蔽(toggleBlockMenu / toggleBlockedSystemSender) + 已迁移至 Vite 模块(resources/js/chat-room/)的内容: + 1. 消息渲染引擎 → message-renderer.js(appendMessage / buildChatMessageContent / 批量渲染 / 裁剪) + 2. 在线用户列表 → user-list.js(renderUserList / filterUserList / 徽标轮换) + 3. WebSocket 事件监听 → chat-events.js(chat:here / chat:message / chat:muted / Echo 级监听等) + 4. 管理操作 → admin-commands.js + admin-menu.js + 5. 存点心跳 → heartbeat.js(saveExp / leaveRoom / notifyExpiredLeave) + 6. 发送消息 → composer.js(sendMessage / 草稿 / IME 防重 / 神秘箱子暗号) + 7. 特效控制 → admin-commands.js + message-utils.js + 8. 系统播报屏蔽 → preferences-status.js(屏蔽 / 禁音 / 每日状态 / 偏好) + 9. 共享状态 → chat-state.js(DOM 引用 / 在线用户 / 消息队列 / 所有可变状态) + 10. 欢迎语菜单 → welcome-menu.js + 11. 每日签到 → daily-sign-in.js + 12. VIP 进退场 → vip-presence.js - 已拆分至独立文件: - - window.chatBanner → chat-banner.blade.php - - 头像选择器 JS → layout/toolbar.blade.php - - 好友通知/chatBanner监听 → user-actions.blade.php - - 红包 HTML+CSS+JS → games/red-packet-panel.blade.php + 保留在 Blade 内的内容(依赖 Blade 模板语法 或 作为薄兼容桥): + 1. switchTab / loadRoomsOnlineStatus(依赖 {{ route('chat.rooms-online-status') }}) + 2. sendWelcomeTpl(依赖 sendMessage 已迁至 Vite) + 3. 点击空白关闭浮层(简单 DOM 事件) + 4. 自动滚屏复选框绑定 + 5. DOMContentLoaded 偏好恢复(调用 Vite 模块函数) + 6. 各类 window.* 兼容桥声明 通过 @include('chat.partials.scripts') 引入到 frame.blade.php @author ChatRoom Laravel - @version 2.0.0 + @version 3.0.0 — 大量逻辑已迁至 Vite 模块,此文件仅保留 Blade 依赖的薄包装 --}} -{{-- 个人装扮样式(消息气泡 / 昵称颜色 / 头像框) --}} - -