Files
chatroom/resources/views/chat/partials/scripts.blade.php
T
2026-04-27 11:12:51 +08:00

3977 lines
154 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能:聊天室核心前端交互脚本(Blade 模板形式)
包含:
1. 消息渲染与路由(appendMessage
2. 在线用户列表管理(renderUserList / filterUserList
3. WebSocket 事件监听(chat:here / chat:message / chat:muted 等)
4. 管理操作(adminClearScreen / promptAnnouncement 等)
5. 存点心跳(saveExp60秒自动)
6. 钓鱼小游戏(startFishing / reelFish / autoFish
7. 发送消息(sendMessageIME 防重触发)
8. 特效控制(triggerEffect / applyFontSize / toggleSoundMute
9. 系统播报屏蔽(toggleBlockMenu / toggleBlockedSystemSender
已拆分至独立文件:
- window.chatBanner chat-banner.blade.php
- 头像选择器 JS layout/toolbar.blade.php
- 好友通知/chatBanner监听 user-actions.blade.php
- 红包 HTML+CSS+JS games/red-packet-panel.blade.php
通过 @include('chat.partials.scripts') 引入到 frame.blade.php
@author ChatRoom Laravel
@version 2.0.0
--}}
{{-- 个人装扮样式(消息气泡 / 昵称颜色 / 头像框) --}}
<style>
/* ========== 消息气泡装扮:在原版逐行消息基础上增加纹理、角标和轻量动效 ========== */
.msg-line[class*="msg-bubble--"] {
position: relative;
isolation: isolate;
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; }
}
/* ========== 头像框 ========== */
.avatar-frame-wrapper {
position: relative;
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
flex: 0 0 44px;
line-height: 0;
}
.avatar-frame-wrapper .user-head {
position: relative;
z-index: 2;
width: 36px;
height: 36px;
border-radius: 50%;
}
.avatar-frame {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.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%;
background: #eaf3ff;
}
.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); }
}
</style>
<script>
/**
* 聊天室前端交互逻辑
* 保留所有 WebSocket 事件监听,复刻原版 UI 交互
*/
// ── DOM 元素引用 ──────────────────────────────────────
const container = document.getElementById('chat-messages-container');
const container2 = document.getElementById('chat-messages-container2');
const userList = document.getElementById('online-users-list');
const toUserSelect = document.getElementById('to_user');
const onlineCount = document.getElementById('online-count');
const onlineCountBottom = document.getElementById('online-count-bottom');
const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = window.ChatRoomTools?.BLOCKED_SYSTEM_SENDERS_STORAGE_KEY || 'chat_blocked_system_senders';
const CHAT_SOUND_MUTED_STORAGE_KEY = window.ChatRoomTools?.CHAT_SOUND_MUTED_STORAGE_KEY || 'chat_sound_muted';
const BLOCKABLE_SYSTEM_SENDERS = window.ChatRoomTools?.BLOCKABLE_SYSTEM_SENDERS || ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子'];
let onlineUsers = {};
// 暂时暴露给已迁移的手机抽屉 Vite 模块读取,后续在线名单整体迁移后可移除。
window.onlineUsers = onlineUsers;
let autoScroll = true;
// 给已迁移到 Vite 的模块只读判断自动滚动状态,避免直接依赖 Blade 脚本作用域。
window.isChatAutoScrollEnabled = () => autoScroll;
// 给 Vite 模块显式写回自动滚屏状态,避免模块和 Blade 闭包各自维护一份状态。
window.setChatAutoScrollEnabled = (enabled) => {
autoScroll = Boolean(enabled);
};
let userBadgeRotationTick = 0;
let userListRenderTimer = null;
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
let pendingChatMessages = [];
let chatMessageFlushTimer = null;
let userFilterRenderTimer = null;
let lastAutosaveNode = null;
const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
const PUBLIC_MESSAGE_NODE_LIMIT = 600;
const PRIVATE_MESSAGE_NODE_LIMIT = 300;
// Vite 模块稍后会覆盖这些全局工具;这里保留极小同步兜底,避免经典脚本早期事件取不到同名函数。
window.escapeHtml = window.escapeHtml || ((text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
});
window.normalizeSafeChatUrl = window.normalizeSafeChatUrl || ((url, fallback) => {
try {
const parsedUrl = new URL(url || fallback, window.location.origin);
if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.origin !== window.location.origin) {
return fallback;
}
return parsedUrl.toString();
} catch (error) {
return fallback;
}
});
window.isExpiredChatImageMessage = window.isExpiredChatImageMessage || ((msg) => {
if (!msg) {
return false;
}
if (msg.message_type === 'expired_image') {
return true;
}
if (msg.message_type !== 'image') {
return false;
}
if (!msg.image_url || !msg.image_thumb_url) {
return true;
}
const retentionDays = parseInt(window.chatContext?.chatImageRetentionDays || 3, 10);
const sentAtText = String(msg.sent_at || '').replace(' ', 'T');
const sentAt = sentAtText ? new Date(sentAtText) : null;
if (!sentAt || Number.isNaN(sentAt.getTime())) {
return false;
}
return Date.now() >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000;
});
const escapeHtml = (...args) => window.escapeHtml(...args);
const normalizeSafeChatUrl = (...args) => window.normalizeSafeChatUrl(...args);
const isExpiredChatImageMessage = (...args) => window.isExpiredChatImageMessage(...args);
const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {});
let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
/**
* 规整聊天室偏好对象,过滤非法配置并补齐默认值。
*
* @param {Record<string, any>} raw 原始偏好对象
* @returns {Object}
*/
function normalizeChatPreferences(raw) {
if (window.ChatRoomTools?.normalizeChatPreferences) {
return window.ChatRoomTools.normalizeChatPreferences(raw, BLOCKABLE_SYSTEM_SENDERS);
}
const blocked = Array.isArray(raw?.blocked_system_senders)
? raw.blocked_system_senders.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender))
: [];
return {
// 默认所有用户都处于“不屏蔽”的开放状态,只有显式勾选的项目才会进入该列表。
blocked_system_senders: Array.from(new Set(blocked)),
sound_muted: Boolean(raw?.sound_muted),
};
}
/**
* 解析并标准化状态到期时间。
*
* @param {string|null|undefined} expiresAt 原始到期时间
* @returns {Date|null}
*/
function parseDailyStatusExpiry(expiresAt) {
if (window.ChatRoomTools?.parseDailyStatusExpiry) {
return window.ChatRoomTools.parseDailyStatusExpiry(expiresAt);
}
if (!expiresAt) {
return null;
}
const parsed = new Date(expiresAt);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* 将状态对象规整为前端统一结构,并过滤掉已过期状态。
*
* @param {Record<string, any>|null|undefined} raw 原始状态对象
* @returns {Object|null}
*/
function normalizeDailyStatus(raw) {
if (window.ChatRoomTools?.normalizeDailyStatus) {
return window.ChatRoomTools.normalizeDailyStatus(raw);
}
if (!raw || typeof raw !== 'object') {
return null;
}
const key = String(raw.key ?? raw.daily_status_key ?? '');
const label = String(raw.label ?? raw.daily_status_label ?? '');
const icon = String(raw.icon ?? raw.daily_status_icon ?? '');
const group = String(raw.group ?? raw.daily_status_group ?? '');
const expiresAt = raw.expires_at ?? raw.daily_status_expires_at ?? null;
const parsedExpiry = parseDailyStatusExpiry(expiresAt);
if (!key || !label || !icon || !parsedExpiry) {
return null;
}
if (parsedExpiry.getTime() <= Date.now()) {
return null;
}
return {
key,
label,
icon,
group,
expires_at: parsedExpiry.toISOString(),
};
}
/**
* 获取当前登录用户仍然有效的状态。
*
* @returns {Object|null}
*/
function getCurrentUserDailyStatus() {
return normalizeDailyStatus(window.chatContext?.currentDailyStatus);
}
/**
* 清除用户在线载荷中的状态字段,避免合并时残留旧状态。
*
* @param {Record<string, any>} payload 用户在线载荷
*/
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 标准化后的状态对象
*/
function setOnlineUserDailyStatus(username, status) {
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;
}
/**
* 用服务端最新的在线载荷刷新指定用户,并先清空旧状态字段。
*
* @param {string} username 用户名
* @param {Record<string, any>} payload 最新在线载荷
*/
function hydrateOnlineUserPayload(username, payload) {
const nextPayload = {
...(onlineUsers[username] || {}),
};
removeDailyStatusFields(nextPayload);
onlineUsers[username] = {
...nextPayload,
...payload,
};
}
/**
* 同步状态按钮文字与图标。
*/
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。
*/
function syncDailyStatusUi() {
const activeStatus = getCurrentUserDailyStatus();
if (window.chatContext) {
window.chatContext.currentDailyStatus = activeStatus;
}
syncDailyStatusTrigger();
syncDailyStatusMenuSelection();
}
/**
* 关闭功能菜单。
*/
function closeFeatureMenu() {
if (window.ChatRoomTools?.closeFeatureMenu) {
window.ChatRoomTools.closeFeatureMenu();
return;
}
const menu = document.getElementById('feature-menu');
if (menu) {
menu.style.display = 'none';
}
}
/**
* 切换功能菜单显示状态。
*
* @param {Event} event 点击事件
*/
function toggleFeatureMenu(event) {
if (window.ChatRoomTools?.toggleFeatureMenu) {
window.ChatRoomTools.toggleFeatureMenu(event, syncDailyStatusUi);
return;
}
event?.stopPropagation?.();
const menu = document.getElementById('feature-menu');
if (!menu) {
return;
}
['welcome-menu', 'admin-menu', 'block-menu', 'daily-status-editor-overlay'].forEach((id) => {
const panel = document.getElementById(id);
if (panel) {
panel.style.display = 'none';
}
});
syncDailyStatusUi();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
/**
* 打开状态编辑窗口。
*/
function openDailyStatusEditor() {
if (window.ChatRoomTools?.openDailyStatusEditor) {
window.ChatRoomTools.openDailyStatusEditor(syncDailyStatusUi);
return;
}
closeFeatureMenu();
syncDailyStatusUi();
const overlay = document.getElementById('daily-status-editor-overlay');
if (overlay) {
overlay.style.display = 'block';
}
}
/**
* 关闭状态编辑窗口。
*/
function closeDailyStatusEditor() {
if (window.ChatRoomTools?.closeDailyStatusEditor) {
window.ChatRoomTools.closeDailyStatusEditor();
return;
}
const overlay = document.getElementById('daily-status-editor-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
/**
* 提交状态设置/清除请求。
*
* @param {Object} payload 请求载荷
* @returns {Promise<Object>}
*/
async function submitDailyStatusPayload(payload) {
const response = await fetch(window.chatContext.dailyStatusUpdateUrl, {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok || result?.status !== 'success') {
throw new Error(result?.message || '状态保存失败');
}
return result;
}
/**
* 将服务端返回的状态结果应用到当前用户本地名单。
*
* @param {Object|null} status 标准化后的状态对象
*/
function applyCurrentUserDailyStatus(status) {
if (window.chatContext) {
window.chatContext.currentDailyStatus = status;
}
setOnlineUserDailyStatus(window.chatContext.username, status);
syncDailyStatusUi();
renderUserList();
}
/**
* 从服务端响应中提取最新金币余额。
*
* @param {Record<string, any>} data 接口响应数据
* @returns {number|null}
*/
function resolveDailySignInGoldBalance(data) {
const candidates = [
data?.data?.user?.jjb,
data?.data?.user?.gold,
data?.data?.presence?.jjb,
data?.data?.presence?.gold,
data?.data?.my_jjb,
data?.data?.new_jjb,
data?.data?.balance,
data?.my_jjb,
data?.new_jjb,
data?.balance,
];
for (const candidate of candidates) {
const amount = Number(candidate);
if (Number.isFinite(amount)) {
return amount;
}
}
return null;
}
/**
* 从签到响应中提取当前用户最新在线载荷。
*
* @param {Record<string, any>} data 接口响应数据
* @returns {Record<string, any>|null}
*/
function resolveDailySignInPresencePayload(data) {
const candidates = [
data?.data?.presence,
data?.data?.online_user,
data?.data?.onlineUser,
data?.data?.user_payload,
data?.data?.userPayload,
data?.data?.user,
data?.presence,
data?.online_user,
data?.onlineUser,
];
return candidates.find(payload => payload && typeof payload === 'object') || null;
}
/**
* 从签到响应中提取签到身份字段。
*
* @param {Record<string, any>} data 接口响应数据
* @returns {Record<string, any>}
*/
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,
};
}
/**
* 将签到成功结果同步到金币余额与在线名单。
*
* @param {Record<string, any>} data 接口响应数据
*/
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(username, {
...(payload || {}),
...identityPayload,
username,
});
}
renderUserList();
}
window.dailySignInState = {
month: null,
prevMonth: null,
nextMonth: null,
repairCardItem: null,
repairCardCount: 0,
rewardRules: [],
status: null,
};
/**
* 打开每日签到日历弹窗。
*/
window.openDailySignInModal = 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(window.dailySignInState.month),
]);
};
/**
* 关闭每日签到日历弹窗。
*/
window.closeDailySignInModal = function closeDailySignInModal() {
const modal = document.getElementById('daily-sign-modal');
if (modal) {
modal.style.display = 'none';
}
};
/**
* 快速签到入口:打开日历,让用户能看到当月签到和补签状态。
*/
window.quickDailySignIn = async function quickDailySignIn() {
await window.openDailySignInModal();
};
/**
* 拉取今日签到状态。
*/
async function loadDailySignInStatus() {
const statusUrl = window.chatContext?.dailySignInStatusUrl;
if (!statusUrl) {
return;
}
const response = await fetch(statusUrl, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
});
const data = await response.json();
if (!response.ok || data?.status === 'error') {
throw new Error(data?.message || '签到状态加载失败');
}
window.dailySignInState.status = data.data || {};
renderDailySignInStatus();
}
/**
* 拉取并渲染指定月份签到日历。
*
* @param {string|null|undefined} month 月份 YYYY-MM
*/
window.loadDailySignInCalendar = 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': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
});
const data = await response.json();
if (!response.ok || data?.status === 'error') {
throw new Error(data?.message || '签到日历加载失败');
}
const payload = data.data || {};
window.dailySignInState.month = payload.month || month || null;
window.dailySignInState.prevMonth = payload.prev_month || null;
window.dailySignInState.nextMonth = payload.next_month || null;
window.dailySignInState.repairCardItem = payload.sign_repair_card_item || null;
window.dailySignInState.repairCardCount = Number(payload.makeup_card_count || 0);
window.dailySignInState.rewardRules = Array.isArray(payload.reward_rules) ? payload.reward_rules : [];
renderDailySignInCalendar(payload);
renderDailySignInStatus();
renderDailySignInRewardRules();
};
/**
* 渲染签到状态摘要。
*/
function renderDailySignInStatus() {
const status = window.dailySignInState.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 = window.dailySignInState.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 = `补签卡 ${window.dailySignInState.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';
}
}
/**
* 渲染签到月历。
*
* @param {Record<string, any>} payload 日历响应数据
*/
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(window.dailySignInState.status?.current_streak_days || 0);
const rules = window.dailySignInState.rewardRules || [];
if (progress) {
progress.textContent = `当前 ${currentDays} 天`;
}
if (!rules.length) {
list.innerHTML = '<div style="font-size:12px;color:#94a3b8;padding:4px;">暂无奖励规则</div>';
return;
}
list.innerHTML = rules.map(rule => {
const streakDays = Number(rule.streak_days || 0);
const parts = [];
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward}金`);
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward}经验`);
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward}魅力`);
const icon = escapeHtml(rule.identity_badge_icon || '✅');
const name = escapeHtml(rule.identity_badge_name || '签到奖励');
const color = escapeHtml(rule.identity_badge_color || '#0f766e');
const activeClass = currentDays >= streakDays ? ' active' : '';
const distanceText = currentDays >= streakDays ? '已达成' : `还差 ${Math.max(streakDays - currentDays, 0)} 天`;
const rewardText = escapeHtml(parts.join(' + ') || '签到记录');
return `
<div class="daily-sign-reward-card${activeClass}" title="${name} · ${rewardText} · ${escapeHtml(distanceText)}">
<div class="daily-sign-reward-title">
<span>第 ${streakDays} 天</span>
<span style="color:${color};">${icon}</span>
</div>
<div class="daily-sign-reward-name">${name}</div>
<div class="daily-sign-reward-desc">${rewardText}</div>
</div>
`;
}).join('');
}
/**
* 在日历弹窗中领取今日签到。
*/
window.claimDailySignInFromModal = 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': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'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(window.dailySignInState.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');
}
};
/**
* 使用补签卡补签指定日期。
*
* @param {string} targetDate 目标日期 YYYY-MM-DD
*/
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': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'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(window.dailySignInState.month),
]);
renderDailySignInRewardRules();
window.chatToast?.show({
title: '补签成功',
message: data?.message || '补签已完成。',
icon: '🗓️',
color: '#0f766e',
duration: 3200,
});
} catch (error) {
window.chatDialog?.alert(error.message || '补签失败,请稍后重试。', '补签失败', '#cc4444');
}
}
/**
* 询问补签卡购买数量。
*
* @param {Record<string, any>} item 补签卡商品
* @returns {Promise<number|null>}
*/
window.promptSignRepairQuantity = 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;
};
/**
* 在签到弹窗内快速购买补签卡。
*/
window.buyDailySignRepairCard = async function buyDailySignRepairCard() {
const item = window.dailySignInState.repairCardItem;
if (!item?.id) {
window.chatDialog?.alert('补签卡暂未上架。', '提示', '#f59e0b');
return;
}
const quantity = await window.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(window.dailySignInState.month);
loadDailySignInStatus();
}, 900);
return;
}
window.openShopModal?.();
};
/**
* 设置新的当日状态。
*
* @param {string} statusKey 状态键
*/
async function updateDailyStatus(statusKey) {
if (!window.chatContext?.dailyStatusUpdateUrl) {
return;
}
try {
const result = await submitDailyStatusPayload({
room_id: window.chatContext.roomId,
action: 'set',
status_key: statusKey,
});
const status = normalizeDailyStatus(result?.data?.status);
applyCurrentUserDailyStatus(status);
closeDailyStatusEditor();
window.chatToast?.show({
title: '状态已更新',
message: status ? `${status.icon} ${status.label}` : '已更新',
icon: status?.icon || '🙂',
color: '#4f46e5',
duration: 2600,
});
} catch (error) {
window.chatDialog?.alert(error.message || '状态设置失败', '操作失败', '#cc4444');
}
}
/**
* 清除当前当日状态。
*/
async function clearDailyStatus() {
if (!window.chatContext?.dailyStatusUpdateUrl) {
return;
}
try {
await submitDailyStatusPayload({
room_id: window.chatContext.roomId,
action: 'clear',
});
applyCurrentUserDailyStatus(null);
closeDailyStatusEditor();
window.chatToast?.show({
title: '状态已清除',
message: '名字后方将恢复默认徽标展示。',
icon: '♻️',
color: '#c2410c',
duration: 2400,
});
} catch (error) {
window.chatDialog?.alert(error.message || '状态清除失败', '操作失败', '#cc4444');
}
}
/**
* 从 localStorage 读取已屏蔽的系统播报发送者列表。
*
* @returns {string[]}
*/
function loadBlockedSystemSenders() {
if (window.ChatRoomTools?.loadBlockedSystemSenders) {
return window.ChatRoomTools.loadBlockedSystemSenders(BLOCKABLE_SYSTEM_SENDERS);
}
try {
const saved = JSON.parse(localStorage.getItem(BLOCKED_SYSTEM_SENDERS_STORAGE_KEY) || '[]');
if (!Array.isArray(saved)) {
return [];
}
// 仅允许白名单内的系统播报项进入配置,避免脏数据污染。
return saved.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender));
} catch (error) {
return [];
}
}
/**
* 将当前屏蔽配置持久化到 localStorage。
*/
function persistBlockedSystemSenders() {
if (window.ChatRoomTools?.persistBlockedSystemSenders) {
window.ChatRoomTools.persistBlockedSystemSenders(blockedSystemSenders);
return;
}
localStorage.setItem(
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
JSON.stringify(Array.from(blockedSystemSenders))
);
}
/**
* 判断当前禁音开关是否处于打开状态。
*
* @returns {boolean}
*/
function isSoundMuted() {
if (window.ChatRoomTools?.isSoundMuted) {
return window.ChatRoomTools.isSoundMuted();
}
const muteCheckbox = document.getElementById('sound_muted');
if (muteCheckbox) {
return Boolean(muteCheckbox.checked);
}
return localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === '1';
}
/**
* 获取当前聊天室偏好快照。
*
* @returns {Object}
*/
function buildChatPreferencesPayload() {
return {
blocked_system_senders: Array.from(blockedSystemSenders),
sound_muted: isSoundMuted(),
};
}
/**
* 将聊天室偏好写入本地缓存,供刷新前快速恢复与迁移兜底。
*/
function persistChatPreferencesToLocal() {
persistBlockedSystemSenders();
if (window.ChatRoomTools?.setSoundMuted) {
window.ChatRoomTools.setSoundMuted(isSoundMuted());
return;
}
localStorage.setItem(CHAT_SOUND_MUTED_STORAGE_KEY, isSoundMuted() ? '1' : '0');
}
/**
* 将当前聊天室偏好保存到当前登录账号。
*/
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);
}
}
/**
* 同步屏蔽菜单中的复选框状态。
*/
function syncBlockedSystemSenderCheckboxes() {
const fishingCheckbox = document.getElementById('block-sender-fishing');
const doctorCheckbox = document.getElementById('block-sender-doctor');
const baccaratCheckbox = document.getElementById('block-sender-baccarat');
const horseRaceCheckbox = document.getElementById('block-sender-horse-race');
const mysteryBoxCheckbox = document.getElementById('block-sender-mystery-box');
if (fishingCheckbox) {
fishingCheckbox.checked = blockedSystemSenders.has('钓鱼播报');
}
if (doctorCheckbox) {
doctorCheckbox.checked = blockedSystemSenders.has('星海小博士');
}
if (baccaratCheckbox) {
baccaratCheckbox.checked = blockedSystemSenders.has('百家乐');
}
if (horseRaceCheckbox) {
horseRaceCheckbox.checked = blockedSystemSenders.has('跑马');
}
if (mysteryBoxCheckbox) {
mysteryBoxCheckbox.checked = blockedSystemSenders.has('神秘箱子');
}
}
/**
* 根据消息内容识别其对应的屏蔽规则键。
*
* @param {Record<string, any>} msg 消息对象
* @returns {string|null}
*/
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;
}
/**
* 批量切换当前已渲染消息的显示状态。
*
* @param {string} blockKey 屏蔽规则键
* @param {boolean} hidden true = 隐藏,false = 恢复显示
*/
function setRenderedMessagesVisibilityBySender(blockKey, hidden) {
[container, 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 && autoScroll) {
container.scrollTop = container.scrollHeight;
container2.scrollTop = container2.scrollHeight;
}
}
/**
* 切换系统播报屏蔽菜单的显示状态。
*
* @param {Event} event 点击事件
*/
function toggleBlockMenu(event) {
if (window.ChatRoomTools?.toggleBlockMenu) {
window.ChatRoomTools.toggleBlockMenu(event, syncBlockedSystemSenderCheckboxes);
return;
}
event?.stopPropagation?.();
const menu = document.getElementById('block-menu');
if (!menu) {
return;
}
['welcome-menu', 'admin-menu', 'feature-menu', 'daily-status-editor-overlay'].forEach((id) => {
const panel = document.getElementById(id);
if (panel) {
panel.style.display = 'none';
}
});
syncBlockedSystemSenderCheckboxes();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
/**
* 更新指定系统播报项的屏蔽状态,并在勾选后立即清理当前窗口。
*
* @param {string} sender 系统播报发送者/规则键
* @param {boolean} blocked 是否屏蔽
*/
function toggleBlockedSystemSender(sender, blocked) {
if (!BLOCKABLE_SYSTEM_SENDERS.includes(sender)) {
return;
}
if (blocked) {
blockedSystemSenders.add(sender);
// 勾选后立刻隐藏聊天室窗口内已显示的对应播报内容。
setRenderedMessagesVisibilityBySender(sender, true);
} else {
blockedSystemSenders.delete(sender);
// 取消勾选后立即恢复先前被隐藏的对应播报内容。
setRenderedMessagesVisibilityBySender(sender, false);
}
persistBlockedSystemSenders();
syncBlockedSystemSenderCheckboxes();
void saveChatPreferences();
}
syncBlockedSystemSenderCheckboxes();
/**
* 转义会员横幅文本,避免横幅层被注入 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;
}
/**
* 显示会员进退场豪华横幅。
*/
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.showVipPresenceBanner = showVipPresenceBanner;
/**
* 构建普通聊天消息的正文区域,支持缩略图与过期占位渲染。
*/
function buildChatMessageContent(msg, fontColor) {
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 captionHtml = rawContent ?
`<span style="display:inline-block; max-width:220px; color:${fontColor}; 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 captionHtml = rawContent ?
`<span style="display:inline-block; color:${fontColor}; 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;
}
// ── Tab 切换 ──────────────────────────────────────
let _roomsRefreshTimer = null;
let _roomsOnlineStatusCache = null;
let _roomsOnlineStatusCacheAt = 0;
const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
function switchTab(tab) {
// 切换名单/房间 面板
['users', 'rooms'].forEach(t => {
document.getElementById('panel-' + t).style.display = t === tab ? 'block' : 'none';
document.getElementById('tab-' + t)?.classList.toggle('active', t === tab);
});
// 房间 Tab:立即拉取 + 每 30 秒自动刷新在线人数
if (tab === 'rooms') {
loadRoomsOnlineStatus();
clearInterval(_roomsRefreshTimer);
_roomsRefreshTimer = setInterval(() => loadRoomsOnlineStatus(true), 30000);
} else {
clearInterval(_roomsRefreshTimer);
_roomsRefreshTimer = null;
}
}
/**
* 拉取所有房间在线人数并渲染到右侧面板
*/
const _currentRoomId = {{ $room->id }};
function loadRoomsOnlineStatus(forceRefresh = false) {
const container = document.getElementById('rooms-online-list');
if (!container) {
return;
}
if (!forceRefresh && _roomsOnlineStatusCache && Date.now() - _roomsOnlineStatusCacheAt < ROOMS_ONLINE_STATUS_CACHE_TTL) {
renderRoomsOnlineStatus(_roomsOnlineStatusCache, container);
return;
}
fetch('{{ route('chat.rooms-online-status') }}')
.then(r => r.json())
.then(data => {
_roomsOnlineStatusCache = data;
_roomsOnlineStatusCacheAt = Date.now();
renderRoomsOnlineStatus(data, container);
})
.catch(() => {
container.innerHTML =
'<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
});
}
/**
* 渲染房间在线状态列表。
*
* @param {Object} data 接口返回数据
* @param {HTMLElement} container 目标容器
*/
function renderRoomsOnlineStatus(data, container) {
if (window.ChatRoomTools?.renderRoomsOnlineStatusToContainer) {
window.ChatRoomTools.renderRoomsOnlineStatusToContainer(data, container, {
currentRoomId: _currentRoomId,
variant: 'desktop',
});
return;
}
// 极端慢网下 Vite 可能还未挂载 ChatRoomTools,先显示空态,下一轮刷新会走模块渲染。
container.innerHTML = '<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
}
// ── 欢迎语快捷菜单 ──────────────────────────────────────
/**
* 切换欢迎语下拉浮层的显示/隐藏
*/
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;
}
if (adminMenu) {
adminMenu.style.display = 'none';
}
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
/**
* 切换顶部管理菜单的显示状态。
*/
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;
}
if (welcomeMenu) {
welcomeMenu.style.display = 'none';
}
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
/**
* 执行管理菜单中的快捷操作,并在执行前关闭菜单。
*
* @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':
sendRedPacket();
break;
case 'loss-cover':
openAdminBaccaratLossCoverModal();
break;
case 'refresh-all':
refreshAllBrowsers();
break;
default:
break;
}
}
/**
* 选择特效后关闭菜单,并沿用原有管理员特效触发逻辑。
*
* @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': document.querySelector('meta[name="csrf-token"]')?.content || '',
'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');
}
}
/**
* 将选中的欢迎语模板填入输入框,{name} 替换为当前选中的聊天对象,
* 并在前面加上「部门 职务 姓名:」前缀,然后自动发送
*
* @param {string} tpl 欢迎语模板,含 {name} 占位符
*/
function sendWelcomeTpl(tpl) {
const toUser = document.getElementById('to_user')?.value || '大家';
const name = toUser === '大家' ? '大家' : toUser;
const prefix = window.chatContext?.welcomePrefix || window.chatContext?.username || '';
const body = tpl.replace(/\{name\}/g, name);
// 拼接格式:「部门 职务 姓名:欢迎语」
const msg = `${prefix}${body}`;
const input = document.getElementById('content');
if (input) {
input.value = msg;
}
const menu = document.getElementById('welcome-menu');
if (menu) {
menu.style.display = 'none';
}
// 临时把 action 设为「欢迎」,让消息在聊天窗口以瓦蓝边框样式显示
const actionSel = document.getElementById('action');
const prevAction = actionSel?.value || '';
if (actionSel) {
actionSel.value = '欢迎';
}
// 自动触发发送
sendMessage(null).finally(() => {
// 发完后恢复原来的 action
if (actionSel) {
actionSel.value = prevAction;
}
});
}
// 点击页面任意位置关闭浮层;浮层内部点击需要保留当前菜单,便于连续勾选配置。
document.addEventListener('click', function(event) {
const clickedInsideFloatingMenu = event.target instanceof Element
&& event.target.closest('[data-chat-welcome-menu], [data-chat-admin-menu], [data-chat-block-menu], [data-chat-feature-menu], [data-chat-feature-menu-toggle], [data-chat-block-menu-toggle], [data-chat-welcome-menu-toggle], [data-chat-admin-menu-toggle]');
if (clickedInsideFloatingMenu) {
return;
}
const menu = document.getElementById('welcome-menu');
if (menu) {
menu.style.display = 'none';
}
const adminMenu = document.getElementById('admin-menu');
if (adminMenu) {
adminMenu.style.display = 'none';
}
const blockMenu = document.getElementById('block-menu');
if (blockMenu) {
blockMenu.style.display = 'none';
}
const featureMenu = document.getElementById('feature-menu');
if (featureMenu) {
featureMenu.style.display = 'none';
}
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
});
// ── 自动滚屏 ──────────────────────────────────────
const autoScrollEl = document.getElementById('auto_scroll');
if (autoScrollEl) {
autoScrollEl.addEventListener('change', function() {
window.setChatAutoScrollEnabled(this.checked);
});
}
// ── 滚动到底部 ───────────────────────────────────
/**
* 将公聊窗口滚动到最新消息(受 autoScroll 开关控制)
*/
function scrollToBottom() {
if (window.ChatRoomTools?.scrollChatToBottom) {
window.ChatRoomTools.scrollChatToBottom(container, () => autoScroll);
return;
}
if (autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
// ── 渲染在线人员列表(支持排序) ──────────────────
/**
* 核心渲染函数:将在线用户渲染到指定容器(桌面端名单区和手机端抽屉共用)
*
* @param {HTMLElement} targetContainer 目标 DOM 容器
* @param {string} sortBy 排序方式:'default' | 'name' | 'level'
* @param {string} keyword 搜索关键词(小写)
*/
function _renderUserListToContainer(targetContainer, sortBy, keyword) {
if (!targetContainer) return;
const fragment = document.createDocumentFragment();
// 在列表顶部添加"大家"条目(原版风格)
let 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);
// ── AI 小助手(已在 onlineUsers 中动态维护,移除硬编码)──
// 构建用户数组并排序
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;
let 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;' : '';
// ── 昵称颜色装扮 ──
var userNameExtraClass = '';
if (user.name_color) {
userNameExtraClass = ' msg-name--' + user.name_color.replace(/^msg_name_/, '');
}
// ── 头像框装扮 ──
var avatarHtml = '';
if (user.avatar_frame) {
var 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);
}
window._renderUserListToContainer = _renderUserListToContainer;
function renderUserList() {
if (userListRenderTimer) {
window.clearTimeout(userListRenderTimer);
userListRenderTimer = null;
}
const selectFragment = document.createDocumentFragment();
const everyoneOption = document.createElement('option');
everyoneOption.value = '大家';
everyoneOption.textContent = '大家';
selectFragment.appendChild(everyoneOption);
// 获取排序方式和搜索词
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() : '';
// 调用核心渲染(桌面端名单容器)
_renderUserListToContainer(userList, sortBy, keyword);
// 下拉框里如果 AI在场,可以直接选
for (let username in onlineUsers) {
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
let text = username;
if (username === 'AI小班长') {
text = '🤖 AI小班长';
}
option.textContent = text;
selectFragment.appendChild(option);
}
}
toUserSelect.replaceChildren(selectFragment);
const count = Object.keys(onlineUsers).length;
onlineCount.innerText = count;
onlineCountBottom.innerText = count;
const footer = document.getElementById('online-count-footer');
if (footer) {
footer.innerText = count;
}
// 派发用户列表更新事件,供手机端抽屉同步
window.dispatchEvent(new Event('chatroom:users-updated'));
}
/**
* 合并高频在线名单变动,避免 Presence 连续进出时重复重建名单 DOM。
*
* @param {number} delay 等待毫秒数
*/
function scheduleRenderUserList(delay = 120) {
if (userListRenderTimer) {
window.clearTimeout(userListRenderTimer);
}
userListRenderTimer = window.setTimeout(() => {
userListRenderTimer = null;
renderUserList();
}, delay);
}
/**
* 获取用户当前仍然有效的当日状态。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {Object|null}
*/
function resolveUserDailyStatus(user) {
return normalizeDailyStatus(user);
}
/**
* 构建职务 / 管理员徽标。
*
* @param {Record<string, any>} user 用户在线载荷
* @param {string} username 用户名
* @returns {string}
*/
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 徽标。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {string}
*/
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>`;
}
/**
* 构建状态徽标。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {string}
*/
function buildUserStatusBadgeHtml(user) {
const status = resolveUserDailyStatus(user);
if (!status) {
return '';
}
const safeIcon = escapeHtml(status.icon);
const safeLabel = escapeHtml(status.label);
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
return `
<span class="user-badge-icon" style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;max-width:78px;overflow:hidden;white-space:nowrap;border-radius:999px;background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca;font-size:11px;line-height:18px;vertical-align:middle;"
data-instant-tooltip="${safeTooltip}">
<span style="font-size:11px;line-height:1;flex-shrink:0;">${safeIcon}</span>
<span style="line-height:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${safeLabel}</span>
</span>
`;
}
/**
* 构建签到身份徽标。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {string}
*/
function buildUserSignIdentityBadgeHtml(user) {
const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? '');
const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? '');
const identityIcon = String(user.sign_identity_icon ?? '');
if (!identityKey || !identityLabel || !identityIcon) {
return '';
}
const color = String(user.sign_identity_color || '#0f766e');
const bgColor = String(user.sign_identity_bg_color || '#ccfbf1');
const borderColor = String(user.sign_identity_border_color || '#5eead4');
const safeIcon = escapeHtml(identityIcon);
const safeLabel = escapeHtml(identityLabel);
const safeTooltip = escapeHtml(`签到 · ${identityLabel}`);
return `
<span class="user-badge-icon" style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;max-width:78px;overflow:hidden;white-space:nowrap;border-radius:999px;background:${bgColor};border:1px solid ${borderColor};color:${color};font-size:11px;line-height:18px;vertical-align:middle;"
data-instant-tooltip="${safeTooltip}">
<span style="font-size:11px;line-height:1;flex-shrink:0;">${safeIcon}</span>
<span style="line-height:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${safeLabel}</span>
</span>
`;
}
/**
* 按 3 秒节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。
*
* @param {Record<string, any>} user 用户在线载荷
* @param {string} username 用户名
* @returns {string}
*/
function buildUserBadgeHtml(user, username) {
const badges = [
buildUserSignIdentityBadgeHtml(user),
buildUserStatusBadgeHtml(user),
buildUserPrimaryBadgeHtml(user, username),
buildUserVipBadgeHtml(user),
].filter(Boolean);
if (badges.length === 0) {
return '';
}
return badges[userBadgeRotationTick % badges.length];
}
/**
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
*
* @param {HTMLElement|Document} scope 需要刷新的 DOM 范围
*/
function refreshRenderedUserBadges(scope = document) {
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);
});
}
// 名单中多种徽标轮换展示时,每 3 秒只刷新徽标槽位,不重建头像行。
window.setInterval(() => {
if (document.hidden) {
return;
}
userBadgeRotationTick = (userBadgeRotationTick + 1) % 4;
refreshRenderedUserBadges(userList);
const mobileUsersList = document.getElementById('mob-online-users-list');
if (mobileUsersList?.offsetParent !== null) {
refreshRenderedUserBadges(mobileUsersList);
}
syncDailyStatusUi();
}, 3000);
/**
* 调度用户列表搜索过滤,避免每个按键都同步扫描名单 DOM。
*/
function scheduleFilterUserList() {
if (userFilterRenderTimer !== null) {
return;
}
const scheduleFilter = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
userFilterRenderTimer = scheduleFilter(() => {
userFilterRenderTimer = null;
filterUserList();
});
}
/**
* 搜索/过滤用户列表
*/
function filterUserList() {
const searchInput = document.getElementById('user-search-input');
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : '';
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';
});
}
/**
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)
*/
function appendMessage(msg, renderBatch = null) {
// 记录拉取到的最大消息ID,用于本地清屏功能
if (msg && msg.id > _maxMsgId) {
_maxMsgId = msg.id;
}
const isMe = msg.from_user === window.chatContext.username;
const fontColor = msg.font_color || '#000000';
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
const shouldHideByBlock = blockRuleKey ? 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) {
var bubbleStyle = msg.msg_bubble.replace(/^msg_bubble_/, '');
div.classList.add('msg-bubble--' + bubbleStyle);
}
const timeStr = msg.sent_at || '';
let timeStrOverride = false;
var nameClass = '';
if (msg.msg_name_color) {
nameClass = ' msg-name--' + msg.msg_name_color.replace(/^msg_name_/, '');
}
// 系统用户名列表(不可被选为聊天对象)
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', '送花播报', '系统', '欢迎', '系统播报', '神秘箱子'];
// 动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说"
const actionTextMap = {
'微笑': {
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: '偷看了'
},
};
// 生成自然语序的动作串:情绪型=[人][着/地]对[目标][verb]:;动作型=[人][了][目标][verb]
const buildActionStr = (action, fromHtml, toHtml, verb = '说') => {
const info = actionTextMap[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}`;
};
// 判断 【】 内的内容是否是游戏/活动标签而非真实用户名
// 规则:命中已知游戏前缀,或内容含空格(如「双色球 第012期 开奖」)
const isGameLabel = (name) => {
const gamePrefixes = ['五子棋', '双色球', '钓鱼', '老虎机', '百家乐', '赛马'];
if (gamePrefixes.some(p => name.startsWith(p))) return true;
// 含空格 → 一定不是用户名(用户名不允许含空格)
if (name.includes(' ')) return true;
return false;
};
// 用户名(单击切换发言对象,双击查看资料;事件委托已迁至 Vite right-panel.js
const 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 (systemUsers.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>`;
};
// 普通用户(包括 AI小班长)用数据库头像,播报类用特殊喇叭图标
const senderInfo = 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';
}
const 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);
let html = '';
// 第一个判断分支:如果是纯 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'">`;
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
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);';
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#1d4ed8') + '】';
});
html =
`<div style="color: #1e40af;">&#x1F4AC; ${parsedContent} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
}
// 接下来再判断各类发话人
else if (systemUsers.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);';
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#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 === '系统传音') {
// 自动升级播报 / 赠礼通知 / 彩票购买广播:金色左边框,轻量提示样式,不喧宾夺主
// 解析内容中 【用户名】 片段,使其支持单击(切换发言对象)和双击(查看名片)
div.style.cssText =
'background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;';
let sysTranContent = msg.content;
sysTranContent = sysTranContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
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 {
// 其他系统用户(钓鱼播报、送花播报、AI小班长等):普通样式
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 = msg.content;
// 利用正则匹配【用户名】结构,捕获组 $1 即是里面真正的用户名
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
html =
`${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content" 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" 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" 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" 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) {
div.setAttribute('data-system-user', msg.welcome_user);
// 收到后端来的新欢迎消息时,把界面上该用户旧的都删掉
const welcomeSelector = `[data-system-user="${msg.welcome_user}"]`;
const oldWelcomes = container.querySelectorAll(welcomeSelector);
oldWelcomes.forEach(el => el.remove());
renderBatch?.publicFragment.querySelectorAll(welcomeSelector).forEach(el => el.remove());
renderBatch?.privateFragment.querySelectorAll(welcomeSelector).forEach(el => el.remove());
}
// 路由规则(复刻原版):
// 公众窗口(say1):别人的公聊消息
// 包厢窗口(say2):自己发的消息 + 悄悄话 + 对自己说的消息
const isRelatedToMe = isMe ||
msg.is_secret ||
msg.to_user === window.chatContext.username;
// 存点通知:标记 data-autosave 属性,每次渲染时先删除旧的,实现"滚动替换"效果
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) {
lastAutosaveNode?.remove();
lastAutosaveNode = div;
}
if (renderBatch) {
renderBatch.privateFragment.appendChild(div);
renderBatch.shouldPrunePrivate = true;
renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || autoScroll;
return;
}
container2.appendChild(div);
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
if (autoScroll) {
container2.scrollTop = container2.scrollHeight;
}
} else {
if (renderBatch) {
renderBatch.publicFragment.appendChild(div);
renderBatch.shouldPrunePublic = true;
renderBatch.shouldScrollPublic = renderBatch.shouldScrollPublic || autoScroll;
return;
}
container.appendChild(div);
pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
scrollToBottom();
}
}
/**
* 裁剪聊天窗口内过旧的消息节点,防止长时间在线后 DOM 无限膨胀。
*
* @param {HTMLElement} targetContainer 聊天窗口容器
* @param {number} maxNodes 最大保留节点数
*/
function pruneMessageContainer(targetContainer, maxNodes) {
if (!targetContainer || targetContainer.childElementCount <= maxNodes) {
return;
}
while (targetContainer.childElementCount > maxNodes) {
const firstNode = targetContainer.firstElementChild;
if (firstNode === lastAutosaveNode) {
lastAutosaveNode = null;
}
firstNode?.remove();
}
}
/**
* 创建聊天消息批量渲染上下文,集中提交 DOM 变更。
*
* @returns {Object}
*/
function createChatMessageRenderBatch() {
return {
publicFragment: document.createDocumentFragment(),
privateFragment: document.createDocumentFragment(),
shouldPrunePublic: false,
shouldPrunePrivate: false,
shouldScrollPublic: false,
shouldScrollPrivate: false,
};
}
/**
* 提交批量渲染的聊天消息,并把裁剪和滚动合并到每批一次。
*
* @param {Object} renderBatch 批量渲染上下文
*/
function commitChatMessageRenderBatch(renderBatch) {
const hasPublicMessages = renderBatch.publicFragment.childNodes.length > 0;
const hasPrivateMessages = renderBatch.privateFragment.childNodes.length > 0;
if (hasPublicMessages) {
container.appendChild(renderBatch.publicFragment);
}
if (hasPrivateMessages) {
container2.appendChild(renderBatch.privateFragment);
}
if (renderBatch.shouldPrunePublic) {
pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldPrunePrivate) {
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldScrollPublic) {
container.scrollTop = container.scrollHeight;
}
if (renderBatch.shouldScrollPrivate) {
container2.scrollTop = container2.scrollHeight;
}
}
/**
* 将广播消息放入轻量队列,避免连续广播时同步挤占同一帧的 DOM 渲染。
*/
function enqueueChatMessage(msg) {
// 本地清屏依赖最大消息 ID,需要在进入队列时先同步,避免延后渲染导致状态滞后。
if (msg && msg.id > _maxMsgId) {
_maxMsgId = msg.id;
}
pendingChatMessages.push(msg);
if (chatMessageFlushTimer !== null) {
return;
}
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
}
/**
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
*/
function flushQueuedChatMessages() {
chatMessageFlushTimer = null;
const batch = pendingChatMessages.splice(0, CHAT_MESSAGE_FLUSH_BATCH_SIZE);
const renderBatch = createChatMessageRenderBatch();
batch.forEach((msg) => appendMessage(msg, renderBatch));
commitChatMessageRenderBatch(renderBatch);
if (pendingChatMessages.length === 0) {
return;
}
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
}
/**
* 将消息追加函数暴露到全局,供页面首次加载时回填历史消息使用。
*/
window.appendMessage = appendMessage;
// ── WebSocket 初始化 ─────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
}
// 消息区用户名点击/双击/移动端双触发已迁至 Vite 的 right-panel.js 统一委托。
});
// ── WebSocket 事件监听 ────────────────────────────
window.addEventListener('chat:here', (e) => {
const users = e.detail;
onlineUsers = {};
// onlineUsers 使用 let 重建对象时,需要同步 window 引用给 Vite 模块。
window.onlineUsers = onlineUsers;
users.forEach(u => {
hydrateOnlineUserPayload(u.username, u);
});
// 初始加载时,如果全局且开启,注入 AI
if (window.chatContext.chatBotEnabled && window.chatContext.botUser) {
hydrateOnlineUserPayload('AI小班长', window.chatContext.botUser);
}
setOnlineUserDailyStatus(window.chatContext.username, getCurrentUserDailyStatus());
syncDailyStatusUi();
scheduleRenderUserList(0);
});
// 监听机器人动态开关
window.addEventListener('chat:bot-toggled', (e) => {
const detail = e.detail;
window.chatContext.chatBotEnabled = detail.isOnline;
if (detail.isOnline && detail.user && detail.user.username) {
hydrateOnlineUserPayload(detail.user.username, detail.user);
window.chatContext.botUser = detail.user;
} else {
delete onlineUsers['AI小班长'];
window.chatContext.botUser = null;
}
scheduleRenderUserList();
});
window.addEventListener('chat:user-status-updated', (e) => {
const username = e.detail?.username;
const payload = e.detail?.user;
if (!username || !payload) {
return;
}
hydrateOnlineUserPayload(username, payload);
if (username === window.chatContext.username) {
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
syncDailyStatusUi();
}
scheduleRenderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
hydrateOnlineUserPayload(user.username, user);
scheduleRenderUserList();
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
scheduleRenderUserList();
});
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') {
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,
});
}
});
window.addEventListener('chat:kicked', (e) => {
if (e.detail.username === window.chatContext.username) {
window.chatDialog.alert('您已被管理员踢出房间!' + (e.detail.reason ? ' 原因:' + e.detail.reason : ''), '系统通知',
'#cc4444');
window.location.href = "{{ route('rooms.index') }}";
}
});
// ── 禁言状态(本地计时器) ──
let isMutedUntil = 0;
window.addEventListener('chat:muted', (e) => {
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;
// 禁言通知:自己被禁言显示在包厢(say2),其他人显示在公聊(say1)
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') : container;
if (targetContainer) {
targetContainer.appendChild(div);
targetContainer.scrollTop = targetContainer.scrollHeight;
}
// 如果是自己被禁言,设置本地禁言计时
if (isMe && d.mute_time > 0) {
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(() => {
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);
}
}
});
window.addEventListener('chat:title-updated', (e) => {
document.getElementById('room-title-display').innerText = e.detail.title;
});
/**
* 收到站长的全员刷新通知后,先弹出提示,再延迟刷新页面。
*/
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);
});
/**
* 任命/撤职后,目标用户收到定向刷新通知,自动同步页面上的权限按钮。
*/
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);
});
// ── 管理员全员清屏事件(等待 Echo 就绪后监听) ───────
function setupScreenClearedListener() {
if (!window.Echo || !window.chatContext) {
// Echo 或 chatContext 还没就绪,延迟重试
setTimeout(setupScreenClearedListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen('ScreenCleared', (e) => {
console.log('收到全员清屏事件:', 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 => {
// 保留悄悄话消息(含 msg-secret 类)
if (!item.querySelector('.msg-secret')) {
item.remove();
}
});
}
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;
}
});
console.log('ScreenCleared 监听器已注册');
}
// DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载
document.addEventListener('DOMContentLoaded', setupScreenClearedListener);
/**
* 注册房间级“刷新全员”监听。
*
* 放在 Blade 脚本内,避免前端资源未重新构建时收不到刷新广播。
*/
function setupRoomBrowserRefreshListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRoomBrowserRefreshListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen('BrowserRefreshRequested', (e) => {
console.log('收到全员刷新事件:', e);
window.dispatchEvent(
new CustomEvent('chat:browser-refresh-requested', {
detail: e
})
);
});
console.log('BrowserRefreshRequested 监听器已注册');
}
document.addEventListener('DOMContentLoaded', setupRoomBrowserRefreshListener);
// ── 开发日志发布通知(仅 Room 1 大厅可见)────────────
/**
* 监听 ChangelogPublished 事件,在大厅聊天区展示系统通知
* 通知包含版本号、标题和可点击的查看链接
* 复用「系统传音」样式(金色左边框,不喧宾夺主)
*/
function setupChangelogPublishedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupChangelogPublishedListener, 500);
return;
}
// 仅在 Room 1(星光大厅)时监听
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 safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, '{{ route('changelog.index') }}'));
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;
}
});
console.log('ChangelogPublished 监听器已注册(Room 1 专属)');
}
document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener);
// ── 五子棋 PvP 邀请通知(聊天室内显示「接受挑战」按钮)───────
/**
* 监听 .gomoku.invite 事件,在聊天窗口追加邀请消息行。
* 发起者收到的邀请(自己发出的)不显示接受按钮。
*/
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 ?
// 自己的邀请:只显示打开面板按钮,点击事件交给 Vite game-panels.js 委托。
`<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>` :
// 别人的邀请:显示接受挑战按钮,业务仍复用五子棋面板的 acceptGomokuInvite。
`<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;
}
// 60 秒后移除接受按钮(邀请超时)
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) => {
// 对局结束:在公聊展示战报(仅 PvP 有战报意义)
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;
}
});
console.log('[五子棋] 邀请监听器已注册');
}
document.addEventListener('DOMContentLoaded', setupGomokuInviteListener);
// ── 全屏特效事件监听(管理员菜单 / 会员进出场通用)─────────
window.addEventListener('chat:effect', (e) => {
const type = e.detail?.type;
const target = e.detail?.target_username; // null = 全员,otherwise 指定昵称
const operator = e.detail?.operator; // 定向赠送时,购买者自己也应能看到特效
const myName = window.chatContext?.username;
// null 表示全员;若有指定接收者,则购买者本人和指定用户都播放
if (type && typeof EffectManager !== 'undefined') {
if (!target || target === myName || operator === myName) {
EffectManager.play(type);
}
}
});
/**
* 管理员点击特效按钮,向后端 POST /command/effect
*
* @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': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
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));
}
window.toggleAdminMenu = toggleAdminMenu;
window.toggleBlockMenu = toggleBlockMenu;
window.toggleFeatureMenu = toggleFeatureMenu;
window.closeFeatureMenu = closeFeatureMenu;
window.openDailyStatusEditor = openDailyStatusEditor;
window.closeDailyStatusEditor = closeDailyStatusEditor;
window.runAdminAction = runAdminAction;
window.selectEffect = selectEffect;
window.triggerEffect = triggerEffect;
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
window.updateDailyStatus = updateDailyStatus;
window.clearDailyStatus = clearDailyStatus;
window.handleFeatureLocalClear = handleFeatureLocalClear;
syncDailyStatusUi();
// 页面加载后从 localStorage 恢复之前保存的字号
document.addEventListener('DOMContentLoaded', () => {
window.ChatRoomTools?.restoreChatFontSize?.();
const storedBlockedSystemSenders = loadBlockedSystemSenders();
const mutedFromLocal = localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === '1';
const shouldMigrateLocalPreferences = window.ChatRoomTools?.shouldMigrateLocalChatPreferences
? window.ChatRoomTools.shouldMigrateLocalChatPreferences(initialChatPreferences, storedBlockedSystemSenders, mutedFromLocal)
: !(initialChatPreferences.blocked_system_senders.length > 0 || initialChatPreferences.sound_muted)
&& (storedBlockedSystemSenders.length > 0 || mutedFromLocal);
if (shouldMigrateLocalPreferences) {
blockedSystemSenders = new Set(storedBlockedSystemSenders);
}
// 恢复禁音复选框状态;默认一律为未禁音。
const muted = shouldMigrateLocalPreferences ? mutedFromLocal : initialChatPreferences.sound_muted;
if (window.ChatRoomTools?.setSoundMuted) {
window.ChatRoomTools.setSoundMuted(muted);
} else {
const muteChk = document.getElementById('sound_muted');
if (muteChk) muteChk.checked = muted;
}
syncBlockedSystemSenderCheckboxes();
if (shouldMigrateLocalPreferences) {
void saveChatPreferences();
} else {
persistChatPreferencesToLocal();
}
});
// ── 特效禁音开关 ─────────────────────────────────────────────────
/**
* 切换特效音效的静音状态,持久化到 localStorage。
* 开启禁音后立即停止当前正在播放的音效。
*
* @param {boolean} muted true = 禁音,false = 开启声音
*/
function toggleSoundMute(muted) {
if (window.ChatRoomTools?.toggleSoundMute) {
window.ChatRoomTools.toggleSoundMute(muted, () => saveChatPreferences());
return;
}
localStorage.setItem(CHAT_SOUND_MUTED_STORAGE_KEY, muted ? '1' : '0');
if (muted && typeof EffectSounds !== 'undefined') {
EffectSounds.stop();
}
void saveChatPreferences();
}
window.toggleSoundMute = toggleSoundMute;
window.ChatRoomTools?.bindSoundMuteControl?.(() => saveChatPreferences());
// ── 发送消息(Enter 发送,防 IME 输入法重复触发)────────
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
let _imeComposing = false;
let _isSending = false; // 发送中防重入标记
let _sendStartedAt = 0; // 记录发送开始时间,用于页面恢复后释放异常锁
const _contentInput = document.getElementById('content');
const CHAT_DRAFT_STORAGE_KEY = `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`;
/**
* 将当前输入框内容保存到会话级草稿缓存。
*/
function persistChatDraft(value = null) {
try {
const draft = value ?? _contentInput?.value ?? '';
if (draft === '') {
sessionStorage.removeItem(CHAT_DRAFT_STORAGE_KEY);
return;
}
sessionStorage.setItem(CHAT_DRAFT_STORAGE_KEY, draft);
} catch (_) {
// 会话存储不可用时静默降级,不影响聊天主流程
}
}
/**
* 从会话缓存中恢复聊天草稿。
*
* @returns {string}
*/
function loadChatDraft() {
try {
return sessionStorage.getItem(CHAT_DRAFT_STORAGE_KEY) || '';
} catch (_) {
return '';
}
}
/**
* 将当前输入区状态整理为一份稳定快照,避免直接序列化整张表单。
*
* @returns {Object}
*/
function collectChatComposerState() {
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const imageInput = document.getElementById('chat_image');
const toUserSelect = document.getElementById('to_user');
const actionSelect = document.getElementById('action');
const fontColorInput = document.getElementById('font_color');
const secretCheckbox = document.getElementById('is_secret');
const contentRaw = contentInput?.value ?? '';
const selectedImage = imageInput?.files?.[0] ?? null;
return {
contentInput,
submitBtn,
imageInput,
contentRaw,
content: contentRaw.trim(),
selectedImage,
toUser: toUserSelect?.value || '大家',
action: actionSelect?.value || '',
fontColor: fontColorInput?.value || '',
isSecret: Boolean(secretCheckbox?.checked),
};
}
/**
* 基于当前聊天快照构造稳定的 multipart 请求体。
*
* @param {Object} composerState
* @returns {FormData}
*/
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() {
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();
}
_imeComposing = false;
if (_isSending && Date.now() - _sendStartedAt > 15000) {
const submitBtn = document.getElementById('send-btn');
if (submitBtn) {
submitBtn.disabled = false;
}
_isSending = false;
_sendStartedAt = 0;
}
}
window.handleChatImageSelected = handleChatImageSelected;
syncChatComposerAfterResume();
if (_contentInput) {
_contentInput.addEventListener('input', function() {
persistChatDraft(this.value);
});
// 中文/日文等 IME 组词开始
_contentInput.addEventListener('compositionstart', () => {
_imeComposing = true;
});
// 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉
_contentInput.addEventListener('compositionend', () => {
setTimeout(() => {
_imeComposing = false;
}, 10);
});
_contentInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// IME 正在组词时(如选候选汉字),不触发发送
if (_imeComposing) return;
sendMessage(e);
}
});
}
window.addEventListener('pageshow', syncChatComposerAfterResume);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
syncChatComposerAfterResume();
}
});
window.addEventListener('focus', function() {
setTimeout(syncChatComposerAfterResume, 0);
});
/**
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)
*/
async function sendMessage(e) {
if (e) e.preventDefault();
if (_isSending) return; // 上一次还没结束,忽略
_isSending = true;
_sendStartedAt = Date.now();
// 前端禁言检查
if (isMutedUntil > Date.now()) {
const remaining = Math.ceil((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 container2 = document.getElementById('say2');
if (container2) {
container2.appendChild(muteDiv);
container2.scrollTop = container2.scrollHeight;
}
_isSending = false;
_sendStartedAt = 0;
return;
}
const composerState = collectChatComposerState();
const {
contentInput,
submitBtn,
content,
contentRaw,
selectedImage,
toUser,
} = composerState;
if (!content && !selectedImage) {
contentInput.focus();
_isSending = false;
_sendStartedAt = 0;
return;
}
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
if (toUser === 'AI小班长' && content) {
sendToChatBot(content, composerState.isSecret); // 异步调用,私聊状态一并传递
}
// ── 神秘箱子暗号拦截 ────────────────────────────────────
// 当用户输入内容符合暗号格式(4-8位大写字母/数字)时,主动尝试领取
// 不依赖轮询标志,服务端负责校验是否真有活跃箱子及暗号是否匹配
const passcodePattern = /^[A-Z0-9]{4,8}$/;
if (!selectedImage && passcodePattern.test(content.trim())) {
_isSending = false;
_sendStartedAt = 0;
try {
const claimRes = await fetch('/mystery-box/claim', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'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': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'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;
_isSending = false; // 释放发送锁,允许下次发送
_sendStartedAt = 0;
}
}
// ── 设置房间公告 ─────────────────────────────────────
function promptAnnouncement() {
// 从 marquee 读取当前公告全文,剥离末尾的「——发送者 日期」元信息,仅预填纯内容
const fullText = document.getElementById('announcement-text')?.textContent?.trim() || '';
const pureText = fullText.replace(/ ——\S+ \d{2}-\d{2} \d{2}:\d{2}$/, '').trim();
// 使用全局弹窗替代原生 prompt(),通过 .then() 注册回调确保事件正确触发
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': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'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() {
// 使用全局弹窗替代原生 prompt(),通过 .then() 注册回调确保事件正确触发
window.chatDialog.prompt('请输入公屏讲话内容:', '', '📢 公屏讲话', '#7c3aed').then(content => {
if (!content || !content.trim()) return;
fetch('/command/announce', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'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() {
// 使用全局弹窗替代原生 confirm(),通过 .then() 注册回调确保事件正确触发
window.chatDialog.confirm('确定要清除所有人的聊天记录吗?(悄悄话将保留)', '全员清屏', '#dc2626').then(ok => {
if (!ok) return;
fetch('/command/clear-screen', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'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');
});
});
}
// ── 本地清屏(仅限自己的屏幕)───────────────────────────
function localClearScreen() {
lastAutosaveNode = null;
if (window.ChatRoomTools?.localClearScreen) {
window.ChatRoomTools.localClearScreen(window.chatContext.roomId, _maxMsgId);
return;
}
const say1 = document.getElementById('chat-messages-container');
const say2 = document.getElementById('chat-messages-container2');
if (say1) say1.innerHTML = '';
if (say2) say2.innerHTML = '';
localStorage.setItem(`local_clear_msg_id_${window.chatContext.roomId}`, _maxMsgId);
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: #64748b; font-weight: bold;">🧹 您已执行本地清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
}
/**
* 在状态面板中触发本地清屏,并顺手关闭面板。
*/
function handleFeatureLocalClear() {
if (window.ChatRoomTools?.handleFeatureLocalClear) {
window.ChatRoomTools.handleFeatureLocalClear(localClearScreen);
return;
}
closeFeatureMenu();
localClearScreen();
}
// ── 滚屏开关 ─────────────────────────────────────
function toggleAutoScroll() {
if (window.ChatRoomTools?.toggleAutoScroll) {
window.ChatRoomTools.toggleAutoScroll(
() => autoScroll,
(enabled) => window.setChatAutoScrollEnabled(enabled)
);
return;
}
autoScroll = !autoScroll;
const cb = document.getElementById('auto_scroll');
if (cb) cb.checked = autoScroll;
const statusEl = document.getElementById('scroll-status');
if (statusEl) statusEl.textContent = autoScroll ? '开' : '关';
}
// ── 退出房间 ─────────────────────────────────────
let leaveRequestInFlight = false;
async function leaveRoom() {
if (leaveRequestInFlight) {
return;
}
leaveRequestInFlight = true;
try {
await fetch(window.chatContext.leaveUrl + '?explicit=1', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
} catch (e) {
console.error(e);
}
// 弹出窗口直接关闭,如果不是弹出窗口则跳回首页
window.close();
setTimeout(() => {
window.location.href = '/';
}, 500);
}
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);
}
}
// ── 掉线检测计数器 ──
let heartbeatFailCount = 0;
const MAX_HEARTBEAT_FAILS = 3;
// ── 存点功能(手动 + 自动)─────────────────────
async function saveExp(silent = false) {
if (!window.chatContext || !window.chatContext.heartbeatUrl) return;
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')
.getAttribute('content'),
'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>`;
container2.appendChild(upDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}
if (!silent) {
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>`;
// 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知。
lastAutosaveNode?.remove();
lastAutosaveNode = detailDiv;
container2.appendChild(detailDiv);
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
return;
}
}
} catch (e) {
console.error('存点失败', e);
heartbeatFailCount++;
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
window.chatDialog.alert('⚠️ 与服务器的连接已断开,请检查网络后重新登录。', '连接警告', '#b45309');
window.location.href = '/';
return;
}
if (!silent) {
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML = `<span style="color: red;">【系统】存点失败,请稍后重试</span>`;
container2.appendChild(sysDiv);
}
}
}
// ── 自动存点心跳(每60秒自动存一次)───────────
const HEARTBEAT_INTERVAL = 60 * 1000;
setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
setTimeout(() => saveExp(true), 10000);
</script>