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%);
|
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";
|
} from "./chat-room/compact-shop-panel.js";
|
||||||
export { bindSlotMachineControls, slotFab, slotPanel } from "./chat-room/slot-machine.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 { bindVipControls, buyVip, closeVipModal, openVipModal, saveVipPresenceSettings, switchVipTab } from "./chat-room/vip-controls.js";
|
||||||
|
export { showVipPresenceBanner } from "./chat-room/vip-presence.js";
|
||||||
export {
|
export {
|
||||||
BLOCKABLE_SYSTEM_SENDERS,
|
BLOCKABLE_SYSTEM_SENDERS,
|
||||||
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
|
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
|
||||||
CHAT_SOUND_MUTED_STORAGE_KEY,
|
CHAT_SOUND_MUTED_STORAGE_KEY,
|
||||||
bindBlockMenuControls,
|
bindBlockMenuControls,
|
||||||
bindSoundMuteControl,
|
bindSoundMuteControl,
|
||||||
|
buildChatPreferencesPayload,
|
||||||
closeDailyStatusEditor,
|
closeDailyStatusEditor,
|
||||||
closeFeatureMenu,
|
closeFeatureMenu,
|
||||||
|
getCurrentUserDailyStatus,
|
||||||
handleFeatureLocalClear,
|
handleFeatureLocalClear,
|
||||||
isSoundMuted,
|
isSoundMuted,
|
||||||
loadBlockedSystemSenders,
|
loadBlockedSystemSenders,
|
||||||
@@ -224,9 +227,18 @@ export {
|
|||||||
openDailyStatusEditor,
|
openDailyStatusEditor,
|
||||||
parseDailyStatusExpiry,
|
parseDailyStatusExpiry,
|
||||||
persistBlockedSystemSenders,
|
persistBlockedSystemSenders,
|
||||||
|
persistChatPreferencesToLocal,
|
||||||
|
removeDailyStatusFields,
|
||||||
|
resolveBlockedSystemSenderKey,
|
||||||
|
saveChatPreferences,
|
||||||
|
setOnlineUserDailyStatus,
|
||||||
|
setRenderedMessagesVisibilityBySender,
|
||||||
setSoundMuted,
|
setSoundMuted,
|
||||||
shouldMigrateLocalChatPreferences,
|
shouldMigrateLocalChatPreferences,
|
||||||
|
syncBlockedSystemSenderCheckboxes,
|
||||||
|
syncDailyStatusUi,
|
||||||
toggleBlockMenu,
|
toggleBlockMenu,
|
||||||
|
toggleBlockedSystemSender,
|
||||||
toggleFeatureMenu,
|
toggleFeatureMenu,
|
||||||
toggleSoundMute,
|
toggleSoundMute,
|
||||||
} from "./chat-room/preferences-status.js";
|
} from "./chat-room/preferences-status.js";
|
||||||
@@ -260,6 +272,30 @@ export {
|
|||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
} from "./chat-room/message-utils.js";
|
} 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 { escapeHtml, escapeHtmlWithLineBreaks, normalizeSafeChatUrl } from "./chat-room/html.js";
|
||||||
import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js";
|
import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js";
|
||||||
import { bindChatBanner } from "./chat-room/banner.js";
|
import { bindChatBanner } from "./chat-room/banner.js";
|
||||||
@@ -416,8 +452,10 @@ import {
|
|||||||
CHAT_SOUND_MUTED_STORAGE_KEY,
|
CHAT_SOUND_MUTED_STORAGE_KEY,
|
||||||
bindBlockMenuControls,
|
bindBlockMenuControls,
|
||||||
bindSoundMuteControl,
|
bindSoundMuteControl,
|
||||||
|
buildChatPreferencesPayload,
|
||||||
closeDailyStatusEditor,
|
closeDailyStatusEditor,
|
||||||
closeFeatureMenu,
|
closeFeatureMenu,
|
||||||
|
getCurrentUserDailyStatus,
|
||||||
handleFeatureLocalClear,
|
handleFeatureLocalClear,
|
||||||
isSoundMuted,
|
isSoundMuted,
|
||||||
loadBlockedSystemSenders,
|
loadBlockedSystemSenders,
|
||||||
@@ -426,9 +464,18 @@ import {
|
|||||||
openDailyStatusEditor,
|
openDailyStatusEditor,
|
||||||
parseDailyStatusExpiry,
|
parseDailyStatusExpiry,
|
||||||
persistBlockedSystemSenders,
|
persistBlockedSystemSenders,
|
||||||
|
persistChatPreferencesToLocal,
|
||||||
|
removeDailyStatusFields,
|
||||||
|
resolveBlockedSystemSenderKey,
|
||||||
|
saveChatPreferences,
|
||||||
|
setOnlineUserDailyStatus,
|
||||||
|
setRenderedMessagesVisibilityBySender,
|
||||||
setSoundMuted,
|
setSoundMuted,
|
||||||
shouldMigrateLocalChatPreferences,
|
shouldMigrateLocalChatPreferences,
|
||||||
|
syncBlockedSystemSenderCheckboxes,
|
||||||
|
syncDailyStatusUi,
|
||||||
toggleBlockMenu,
|
toggleBlockMenu,
|
||||||
|
toggleBlockedSystemSender,
|
||||||
toggleFeatureMenu,
|
toggleFeatureMenu,
|
||||||
toggleSoundMute,
|
toggleSoundMute,
|
||||||
} from "./chat-room/preferences-status.js";
|
} from "./chat-room/preferences-status.js";
|
||||||
@@ -462,6 +509,13 @@ import {
|
|||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
} from "./chat-room/message-utils.js";
|
} 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") {
|
if (typeof window !== "undefined") {
|
||||||
bindInstantHoverTooltip();
|
bindInstantHoverTooltip();
|
||||||
|
|
||||||
@@ -672,6 +726,17 @@ if (typeof window !== "undefined") {
|
|||||||
toggleBlockMenu,
|
toggleBlockMenu,
|
||||||
toggleFeatureMenu,
|
toggleFeatureMenu,
|
||||||
toggleSoundMute,
|
toggleSoundMute,
|
||||||
|
buildChatPreferencesPayload,
|
||||||
|
getCurrentUserDailyStatus,
|
||||||
|
persistChatPreferencesToLocal,
|
||||||
|
removeDailyStatusFields,
|
||||||
|
resolveBlockedSystemSenderKey,
|
||||||
|
saveChatPreferences,
|
||||||
|
setOnlineUserDailyStatus,
|
||||||
|
setRenderedMessagesVisibilityBySender,
|
||||||
|
syncBlockedSystemSenderCheckboxes,
|
||||||
|
syncDailyStatusUi,
|
||||||
|
toggleBlockedSystemSender,
|
||||||
bindChatRightPanelControls,
|
bindChatRightPanelControls,
|
||||||
bindRoomStatusControls,
|
bindRoomStatusControls,
|
||||||
normalizeRoomStatus,
|
normalizeRoomStatus,
|
||||||
@@ -695,6 +760,26 @@ if (typeof window !== "undefined") {
|
|||||||
scrollChatToBottom,
|
scrollChatToBottom,
|
||||||
syncAutoScrollControls,
|
syncAutoScrollControls,
|
||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
|
// 聊天室核心引擎
|
||||||
|
bindChatEvents,
|
||||||
|
appendMessage,
|
||||||
|
buildChatMessageContent,
|
||||||
|
commitChatMessageRenderBatch,
|
||||||
|
createChatMessageRenderBatch,
|
||||||
|
enqueueChatMessage,
|
||||||
|
flushQueuedChatMessages,
|
||||||
|
pruneMessageContainer,
|
||||||
|
buildUserBadgeHtml,
|
||||||
|
filterUserList,
|
||||||
|
refreshRenderedUserBadges,
|
||||||
|
renderUserListToContainer,
|
||||||
|
startBadgeRotation,
|
||||||
|
stopBadgeRotation,
|
||||||
|
scheduleFilterUserList,
|
||||||
|
leaveRoom,
|
||||||
|
notifyExpiredLeave,
|
||||||
|
startHeartbeat,
|
||||||
|
stopHeartbeat,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 直接挂载只服务暂未迁移的 Blade 调用点;新代码优先通过模块导入或 ChatRoomTools 复用。
|
// 直接挂载只服务暂未迁移的 Blade 调用点;新代码优先通过模块导入或 ChatRoomTools 复用。
|
||||||
@@ -834,6 +919,13 @@ if (typeof window !== "undefined") {
|
|||||||
window.showShopToast = showShopToast;
|
window.showShopToast = showShopToast;
|
||||||
window.submitRename = submitRename;
|
window.submitRename = submitRename;
|
||||||
|
|
||||||
|
// 聊天室核心引擎 window 挂载
|
||||||
|
window.bindChatEvents = bindChatEvents;
|
||||||
|
window.startBadgeRotation = startBadgeRotation;
|
||||||
|
window.stopBadgeRotation = stopBadgeRotation;
|
||||||
|
window.startHeartbeat = startHeartbeat;
|
||||||
|
window.stopHeartbeat = stopHeartbeat;
|
||||||
|
|
||||||
// 页面加载后立即注册事件委托,具体业务逻辑仍由各子模块负责。
|
// 页面加载后立即注册事件委托,具体业务逻辑仍由各子模块负责。
|
||||||
bindChatBanner();
|
bindChatBanner();
|
||||||
bindChatBotControls();
|
bindChatBotControls();
|
||||||
@@ -884,4 +976,7 @@ if (typeof window !== "undefined") {
|
|||||||
bindMobileDrawerControls();
|
bindMobileDrawerControls();
|
||||||
bindWelcomeMenuControls();
|
bindWelcomeMenuControls();
|
||||||
bindBlockMenuControls();
|
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。
|
// 聊天室管理菜单事件绑定,替代 input-bar 中的管理类内联 onclick。
|
||||||
|
// 管理动作业务逻辑已迁至 admin-commands.js。
|
||||||
|
|
||||||
|
import "./admin-commands.js";
|
||||||
|
|
||||||
let adminMenuEventsBound = false;
|
let adminMenuEventsBound = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定管理菜单、管理动作与全屏特效选择事件。
|
* 绑定管理菜单、管理动作与全屏特效选择事件。
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
*/
|
||||||
export function bindAdminMenuControls() {
|
export function bindAdminMenuControls() {
|
||||||
if (adminMenuEventsBound || typeof document === "undefined") {
|
if (adminMenuEventsBound || typeof document === "undefined") {
|
||||||
@@ -22,33 +23,26 @@ export function bindAdminMenuControls() {
|
|||||||
if (menuToggle) {
|
if (menuToggle) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.toggleAdminMenu?.(event);
|
window.toggleAdminMenu?.(event);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminAction = event.target.closest("[data-chat-admin-action]");
|
const adminAction = event.target.closest("[data-chat-admin-action]");
|
||||||
if (adminAction) {
|
if (adminAction) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 管理菜单只负责入口分发,权限校验和实际动作仍由后端与原有全局函数负责。
|
|
||||||
const action = adminAction.getAttribute("data-chat-admin-action") || "";
|
const action = adminAction.getAttribute("data-chat-admin-action") || "";
|
||||||
if (action && typeof window.runAdminAction === "function") {
|
if (action && typeof window.runAdminAction === "function") {
|
||||||
window.runAdminAction(action);
|
window.runAdminAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectButton = event.target.closest("[data-chat-admin-effect]");
|
const effectButton = event.target.closest("[data-chat-admin-effect]");
|
||||||
if (effectButton) {
|
if (effectButton) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 特效按钮只触发管理员发起请求,实际播放仍由 chat:effect 广播和 EffectManager 处理。
|
|
||||||
const effect = effectButton.getAttribute("data-chat-admin-effect") || "";
|
const effect = effectButton.getAttribute("data-chat-admin-effect") || "";
|
||||||
if (effect && typeof window.selectEffect === "function") {
|
if (effect && typeof window.selectEffect === "function") {
|
||||||
window.selectEffect(effect);
|
window.selectEffect(effect);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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;
|
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 入口。
|
*/
|
||||||
*
|
function collectChatComposerState() {
|
||||||
* @returns {void}
|
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() {
|
export function bindChatComposerControls() {
|
||||||
if (chatComposerEventsBound || typeof document === "undefined") {
|
if (chatComposerEventsBound || typeof document === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
chatComposerEventsBound = true;
|
chatComposerEventsBound = true;
|
||||||
|
|
||||||
|
// 表单提交
|
||||||
document.addEventListener("submit", (event) => {
|
document.addEventListener("submit", (event) => {
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
if (!(form instanceof HTMLFormElement) || !form.matches("[data-chat-form]")) {
|
if (!(form instanceof HTMLFormElement) || !form.matches("[data-chat-form]")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (typeof window.sendMessage === "function") {
|
if (typeof window.sendMessage === "function") {
|
||||||
void window.sendMessage(event);
|
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(
|
export function setChatComposerAction(
|
||||||
action,
|
action,
|
||||||
@@ -47,7 +354,6 @@ export function setChatComposerAction(
|
|||||||
actionSelect.value = action;
|
actionSelect.value = action;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右侧在线名单切换仍在 Blade 主脚本内,模块只通过兼容入口调用。
|
|
||||||
if (typeof switchTabHandler === "function") {
|
if (typeof switchTabHandler === "function") {
|
||||||
switchTabHandler("users");
|
switchTabHandler("users");
|
||||||
}
|
}
|
||||||
@@ -56,3 +362,15 @@ export function setChatComposerAction(
|
|||||||
contentInput.focus();
|
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;
|
let dailySignInEventsBound = false;
|
||||||
|
|
||||||
/**
|
// ── 状态(全局共享,兼容 Blade 中 window.dailySignInState 引用)──
|
||||||
* 调用每日签到存量全局函数。
|
window.dailySignInState = window.dailySignInState || {
|
||||||
*
|
month: null,
|
||||||
* @param {string} functionName 全局函数名
|
prevMonth: null,
|
||||||
* @param {...unknown} args 参数
|
nextMonth: null,
|
||||||
* @returns {void}
|
repairCardItem: null,
|
||||||
*/
|
repairCardCount: 0,
|
||||||
function callDailySignInGlobal(functionName, ...args) {
|
rewardRules: [],
|
||||||
// 当前模块只负责 data-* 事件到旧函数的桥接,接口请求和日历渲染暂不迁移。
|
status: null,
|
||||||
if (typeof window[functionName] === "function") {
|
};
|
||||||
window[functionName](...args);
|
|
||||||
}
|
// ── 辅助函数 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function csrf() {
|
||||||
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取每日签到月份翻页目标。
|
* 从服务端响应中提取最新金币余额。
|
||||||
*
|
|
||||||
* @param {"prev"|"next"|string} direction 翻页方向
|
|
||||||
* @returns {string|null}
|
|
||||||
*/
|
*/
|
||||||
function resolveDailySignInMonth(direction) {
|
function resolveDailySignInGoldBalance(data) {
|
||||||
if (direction === "prev") {
|
const candidates = [
|
||||||
return window.dailySignInState?.prevMonth || null;
|
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") {
|
for (const candidate of candidates) {
|
||||||
return window.dailySignInState?.nextMonth || null;
|
const amount = Number(candidate);
|
||||||
|
if (Number.isFinite(amount)) return amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件。
|
* 从签到响应中提取当前用户最新在线载荷。
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
*/
|
||||||
export function bindDailySignInControls() {
|
function resolveDailySignInPresencePayload(data) {
|
||||||
if (dailySignInEventsBound || typeof document === "undefined") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dailySignInEventsBound = true;
|
list.innerHTML = rules.map(rule => {
|
||||||
document.addEventListener("click", (event) => {
|
const streakDays = Number(rule.streak_days || 0);
|
||||||
if (!(event.target instanceof Element)) {
|
const parts = [];
|
||||||
return;
|
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]");
|
const overlay = event.target.closest("[data-daily-sign-modal-overlay]");
|
||||||
// 只在点击遮罩本身时关闭,避免点击弹窗内容区误触关闭。
|
|
||||||
if (overlay && event.target === overlay) {
|
if (overlay && event.target === overlay) {
|
||||||
callDailySignInGlobal("closeDailySignInModal");
|
closeDailySignInModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest("[data-daily-sign-close]")) {
|
if (event.target.closest("[data-daily-sign-close]")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
callDailySignInGlobal("closeDailySignInModal");
|
closeDailySignInModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest("[data-daily-sign-claim]")) {
|
if (event.target.closest("[data-daily-sign-claim]")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
callDailySignInGlobal("claimDailySignInFromModal");
|
claimDailySignInFromModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest("[data-daily-sign-buy-repair-card]")) {
|
if (event.target.closest("[data-daily-sign-buy-repair-card]")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
callDailySignInGlobal("buyDailySignRepairCard");
|
buyDailySignRepairCard();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeupButton = event.target.closest("[data-daily-sign-makeup]");
|
const makeupButton = event.target.closest("[data-daily-sign-makeup]");
|
||||||
if (makeupButton) {
|
if (makeupButton) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// 日历格子由 Blade 主脚本动态生成,这里只读取日期并转发补签旧函数。
|
makeupDailySignIn(makeupButton.getAttribute("data-daily-sign-makeup") || "");
|
||||||
callDailySignInGlobal("makeupDailySignIn", makeupButton.getAttribute("data-daily-sign-makeup") || "");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthButton = event.target.closest("[data-daily-sign-month]");
|
const monthButton = event.target.closest("[data-daily-sign-month]");
|
||||||
if (monthButton) {
|
if (monthButton) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// 月份状态仍由 window.dailySignInState 维护,模块只读取方向并转发旧加载函数。
|
loadDailySignInCalendar(resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
|
||||||
callDailySignInGlobal("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") {
|
if (typeof onLocalClear === "function") {
|
||||||
onLocalClear();
|
onLocalClear();
|
||||||
|
} else if (typeof window.localClearScreen === "function") {
|
||||||
|
// 默认调用聊天室清屏函数,将当前可见消息全部移除。
|
||||||
|
window.localClearScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,3 +466,390 @@ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlocke
|
|||||||
|
|
||||||
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted);
|
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}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
// ── 挂载到 window ──
|
||||||
|
window.runFeatureShortcut = runFeatureShortcut;
|
||||||
|
|
||||||
export function bindToolbarControls() {
|
export function bindToolbarControls() {
|
||||||
if (toolbarEventsBound || typeof document === "undefined") {
|
if (toolbarEventsBound || typeof document === "undefined") {
|
||||||
return;
|
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;
|
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() {
|
export function bindWelcomeMenuControls() {
|
||||||
if (welcomeMenuEventsBound || typeof document === "undefined") {
|
if (welcomeMenuEventsBound || typeof document === "undefined") {
|
||||||
@@ -19,32 +40,28 @@ export function bindWelcomeMenuControls() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 欢迎语菜单外部点击关闭仍由主脚本处理,这里只负责菜单按钮与菜单内部。
|
|
||||||
const toggleButton = event.target.closest("[data-chat-welcome-menu-toggle]");
|
const toggleButton = event.target.closest("[data-chat-welcome-menu-toggle]");
|
||||||
if (toggleButton) {
|
if (toggleButton) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.toggleWelcomeMenu?.(event);
|
toggleWelcomeMenu(event);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menu = event.target.closest("[data-chat-welcome-menu]");
|
const menu = event.target.closest("[data-chat-welcome-menu]");
|
||||||
if (!menu) {
|
if (!menu) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 阻止菜单内部点击冒泡,避免选择模板时被外层关闭逻辑抢先处理。
|
// 阻止菜单内部点击冒泡。
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const item = event.target.closest("[data-chat-welcome-template]");
|
const item = event.target.closest("[data-chat-welcome-template]");
|
||||||
if (!item || !menu.contains(item)) {
|
if (!item || !menu.contains(item)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = item.getAttribute("data-chat-welcome-template") || "";
|
const template = item.getAttribute("data-chat-welcome-template") || "";
|
||||||
// 模板内容只从 data 属性读取,实际发送仍交给旧的 sendWelcomeTpl。
|
// sendWelcomeTpl 仍由 Blade 维护(依赖 sendMessage),这里通过 window 调用。
|
||||||
if (template && typeof window.sendWelcomeTpl === "function") {
|
if (template && typeof window.sendWelcomeTpl === "function") {
|
||||||
window.sendWelcomeTpl(template);
|
window.sendWelcomeTpl(template);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { toggleWelcomeMenu };
|
||||||
|
|||||||
@@ -117,6 +117,9 @@
|
|||||||
'envelopeStatusUrlTemplate' => '/wedding/__ID__/envelope-status',
|
'envelopeStatusUrlTemplate' => '/wedding/__ID__/envelope-status',
|
||||||
],
|
],
|
||||||
'earnRewardUrl' => route('earn.video_reward'),
|
'earnRewardUrl' => route('earn.video_reward'),
|
||||||
|
'roomsOnlineStatusUrl' => route('chat.rooms-online-status'),
|
||||||
|
'changelogUrl' => route('changelog.index'),
|
||||||
|
'roomsIndexUrl' => route('rooms.index'),
|
||||||
'chatImageRetentionDays' => 3,
|
'chatImageRetentionDays' => 3,
|
||||||
'initialState' => [
|
'initialState' => [
|
||||||
'historyMessages' => $historyMessages ?? [],
|
'historyMessages' => $historyMessages ?? [],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user