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