Files
chatroom/resources/views/chat/partials/scripts.blade.php
lkddi 5c53b8cf2f 功能:window.chatBanner 全局大卡片公共组件
前端:
- window.chatBanner.show(options) 全局 API,完全自定义:
  icon/title/name/body/sub/gradient/titleColor/autoClose/buttons
- window.chatBanner.close(id) 关闭指定 banner
- showFriendBanner / showAppointmentBanner 均改用 chatBanner 实现
- setupBannerNotification() 监听私有+房间频道的 BannerNotification 事件

后端:
- BannerNotification 事件(ShouldBroadcastNow),支持 user/room 双目标
- BannerBroadcastController(仅超级管理员路由,三层中间件保护)
- 内容字段 strip_tags 净化防 XSS,按钮 action 白名单校验

安全:
- window.chatBanner.show() 被人控制台调用只影响自己,无法推给他人
- HTTP 入口 POST /admin/banner/broadcast 仅超管可访问
2026-03-01 01:28:23 +08:00

2002 lines
85 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 模板形式)
包含消息渲染、用户列表管理、发送消息、WebSocket 事件监听、
管理操作、存点心跳、头像选择等全部前端逻辑
通过 @include('chat.partials.scripts') 引入到 frame.blade.php
@author ChatRoom Laravel
@version 1.0.0
--}}
<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');
let onlineUsers = {};
let autoScroll = true;
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
// ── Tab 切换 ──────────────────────────────────────
function switchTab(tab) {
// 切换名单/房间/贴图/酷库 面板
['users', 'rooms', 'emoji', 'action'].forEach(t => {
document.getElementById('panel-' + t).style.display = t === tab ? 'block' : 'none';
document.getElementById('tab-' + t).classList.toggle('active', t === tab);
});
// 贴图 Tab 懒加载
if (tab === 'emoji') {
document.querySelectorAll('#panel-emoji img[data-src]').forEach(img => {
img.src = img.dataset.src;
img.removeAttribute('data-src');
});
}
}
// ── 分屏切换 ──────────────────────────────────────
function changeSplitScreen(mode) {
const panes = document.getElementById('message-panes');
if (mode === '1') {
panes.classList.add('split-h');
} else {
panes.classList.remove('split-h');
}
}
// ── 表情插入 ──────────────────────────────────────
function insertEmoji(code) {
const input = document.getElementById('content');
input.value += code;
input.focus();
switchTab('users');
}
// ── 动作选择 ──────────────────────────────────────
function setAction(act) {
document.getElementById('action').value = act;
switchTab('users');
document.getElementById('content').focus();
}
// ── 自动滚屏 ──────────────────────────────────────
const autoScrollEl = document.getElementById('auto_scroll');
if (autoScrollEl) {
autoScrollEl.addEventListener('change', function() {
autoScroll = this.checked;
});
}
// ── 滚动到底部 ───────────────────────────────────
function scrollToBottom() {
if (autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
// ── 渲染在线人员列表(支持排序) ──────────────────
function renderUserList() {
userList.innerHTML = '';
toUserSelect.innerHTML = '<option value="大家">大家</option>';
// 在列表顶部添加"大家"条目(原版风格)
let allDiv = document.createElement('div');
allDiv.className = 'user-item';
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
allDiv.onclick = () => {
toUserSelect.value = '大家';
};
userList.appendChild(allDiv);
// ── AI 小助手(仅当全局开关开启时显示,与普通用户风格一致)──
if (window.chatContext.chatBotEnabled) {
let botDiv = document.createElement('div');
botDiv.className = 'user-item';
botDiv.innerHTML = `
<img class="user-head" src="/images/ai_bot.png" onerror="this.src='/images/headface/1.gif'">
<span class="user-name">AI小班长</span><span style="font-size:12px; margin-left:2px;" title="聊天机器人">🤖</span>
`;
botDiv.onclick = () => {
toUserSelect.value = 'AI小班长';
document.getElementById('content').focus();
};
userList.appendChild(botDiv);
// 在发言对象下拉框中也添加 AI 小助手
let botOption = document.createElement('option');
botOption.value = 'AI小班长';
botOption.textContent = '🤖 AI小班长';
toUserSelect.appendChild(botOption);
}
// 获取排序方式
const sortSelect = document.getElementById('user-sort-select');
const sortBy = sortSelect ? sortSelect.value : 'default';
// 构建用户数组并排序
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));
}
let count = userArr.length;
userArr.forEach(user => {
const username = user.username;
let item = document.createElement('div');
item.className = 'user-item';
item.dataset.username = username;
const headface = (user.headface || '1.gif').toLowerCase();
// 徽章优先级:职务图标 > 管理员 > VIP
let badges = '';
if (user.position_icon) {
// 有职务显示职务图标hover 显示职务名称
const posTitle = (user.position_name || '在职') + ' · ' + username;
badges +=
`<span style="font-size:13px; margin-left:2px;" title="${posTitle}">${user.position_icon}</span>`;
} else if (user.is_admin) {
badges += `<span style="font-size:12px; margin-left:2px;" title="最高统帅">🎖️</span>`;
} else if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
badges +=
`<span style="font-size:12px; margin-left:2px; color:${vipColor};" title="${user.vip_name || 'VIP'}">${user.vip_icon}</span>`;
}
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
item.innerHTML = `
<img class="user-head" src="/images/headface/${headface}" onerror="this.src='/images/headface/1.gif'">
<span class="user-name" style="${nameColor}">${username}</span>${badges}
`;
item.onclick = () => {
toUserSelect.value = username;
document.getElementById('content').focus();
};
// 双击打开用户名片弹窗(全局统一入口)
item.ondblclick = () => openUserCard(username);
userList.appendChild(item);
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
option.textContent = username;
toUserSelect.appendChild(option);
}
});
onlineCount.innerText = count;
onlineCountBottom.innerText = count;
const footer = document.getElementById('online-count-footer');
if (footer) footer.innerText = count;
// 如果有搜索关键词,重新过滤
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) {
// 记录拉取到的最大消息ID用于本地清屏功能
if (msg && msg.id > _maxMsgId) {
_maxMsgId = msg.id;
}
const isMe = msg.from_user === window.chatContext.username;
const fontColor = msg.font_color || '#000000';
const div = document.createElement('div');
div.className = 'msg-line';
const timeStr = msg.sent_at || '';
let timeStrOverride = false;
// 系统用户名列表(不可被选为聊天对象)
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', 'AI小班长', '送花播报', '系统'];
// 动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对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}${action}${verb}`;
if (info.type === 'emotion') return `${fromHtml}${info.word}对${toHtml}${verb}`;
return `${fromHtml}${info.word}${toHtml}${verb}`;
};
// 用户名(单击切换发言对象,双击查看资料;系统用户仅显示文本)
const clickableUser = (uName, color) => {
if (systemUsers.includes(uName)) {
return `<span class="msg-user" style="color: ${color};">${uName}</span>`;
}
return `<span class="msg-user" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="openUserCard('${uName}')">${uName}</span>`;
};
// 系统播报用户(不包含 AI小班长使用军号图标AI小班长用专属图普通用户用头像
const buggleUsers = ['钓鱼播报', '星海小博士', '送花播报', '系统传音', '系统公告'];
const senderInfo = onlineUsers[msg.from_user];
const senderHead = ((senderInfo && senderInfo.headface) || '1.gif').toLowerCase();
let headImgSrc = `/images/headface/${senderHead}`;
if (msg.from_user === 'AI小班长') {
headImgSrc = '/images/ai_bot.png';
} else if (buggleUsers.includes(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'">`;
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 (systemUsers.includes(msg.from_user)) {
if (msg.from_user === '系统公告') {
// 管理员公告:大字醒目红框样式
div.style.cssText =
'background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 6px; padding: 8px 12px; margin: 4px 0; box-shadow: 0 2px 4px rgba(239,68,68,0.15);';
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#dc2626') + '】';
});
html =
`<div style="font-weight: bold; color: #dc2626;">${parsedContent} <span style="color: #999; font-weight: normal;">(${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;';
html =
`<span style="color: #b45309;">🌟 ${msg.content}</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 class="msg-user" style="color: ${fontColor}; font-weight: bold;">${msg.from_user}</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');
const toHtml = clickableUser(msg.to_user, '#cc00cc');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml, '悄悄说') :
`${fromHtml}对${toHtml}悄悄说:`;
html = `<span class="msg-secret">${headImg}${verbStr}${msg.content}</span>`;
}
} else if (msg.to_user && msg.to_user !== '大家') {
// 对特定对象说话
const fromHtml = clickableUser(msg.from_user, '#000099');
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}">${msg.content}</span>`;
} else {
// 对大家说话
const fromHtml = clickableUser(msg.from_user, '#000099');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, '大家') :
`${fromHtml}对大家说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
}
if (!timeStrOverride) {
html += ` <span class="msg-time">(${timeStr})</span>`;
}
div.innerHTML = html;
// 后端下发的带有 welcome_user 的也是系统欢迎/离开消息,加上属性标记
if (msg.welcome_user) {
div.setAttribute('data-system-user', msg.welcome_user);
// 收到后端来的新欢迎消息时,把界面上该用户旧的都删掉
const oldWelcomes = container.querySelectorAll(`[data-system-user="${msg.welcome_user}"]`);
oldWelcomes.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('自动存点');
if (isAutoSave) {
div.dataset.autosave = '1';
}
if (isRelatedToMe) {
// 删除旧的自动存点通知,保持包厢窗口整洁
if (isAutoSave) {
container2.querySelectorAll('[data-autosave="1"]').forEach(el => el.remove());
}
container2.appendChild(div);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
container.appendChild(div);
scrollToBottom();
}
}
// ── WebSocket 初始化 ─────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
}
});
// ── WebSocket 事件监听 ────────────────────────────
window.addEventListener('chat:here', (e) => {
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
});
renderUserList();
// 管理员自己进房时,在本地播放烟花(服务端广播可能在 WS 连上前已发出)
const ctx = window.chatContext;
if (ctx && ctx.userLevel >= ctx.superLevel && typeof EffectManager !== 'undefined') {
// 延迟 800ms 确保页面渲染完成再播特效
setTimeout(() => EffectManager.play('fireworks'), 800);
}
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
renderUserList();
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
renderUserList();
});
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;
}
appendMessage(msg);
});
window.addEventListener('chat:kicked', (e) => {
if (e.detail.username === window.chatContext.username) {
alert("您已被管理员踢出房间!" + (e.detail.reason ? "\n原因:" + e.detail.reason : ""));
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;
});
// ── 管理员全员清屏事件(等待 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 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();
}
});
}
// 显示清屏提示
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>${operator}</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);
// ── 开发日志发布通知(仅 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 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${e.version} · ${e.title}
<a href="${e.url}" 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);
// ── 好友系统私有频道监听(仅本人可见) ────────────────
/**
* 监听当前用户的私有频道 `user.{username}`
* 收到 FriendAdded / FriendRemoved 事件时用弹窗通知。
* FriendAdded → 居中大卡弹窗(同任命公告风格)
* FriendRemoved → 右下角 Toast 通知
*/
function setupFriendNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupFriendNotification, 500);
return;
}
const myName = window.chatContext.username;
window.Echo.private(`user.${myName}`)
.listen('.FriendAdded', (e) => {
// 用居中大卡弹窗通知(有无互相好友显示不同文案和按钮)
showFriendBanner(e.from_username, e.has_added_back);
})
.listen('.FriendRemoved', (e) => {
if (e.had_added_back) {
// 之前是互相好友,现在对方删除了我 → 提示可以同步删除
showFriendToast(
`<60> <b>${e.from_username}</b> 已将你从好友列表移除。<br>
<span style="color:#6b7280; font-size:12px;">你的好友列表中仍保留对方,可点击同步移除。</span>`,
'#6b7280', {
label: `🗑️ 同步移除 ${e.from_username}`,
username: e.from_username,
action: 'remove'
}
);
} else {
showFriendToast(
`<60> <b>${e.from_username}</b> 已将你从他的好友列表移除。`,
'#9ca3af'
);
}
});
}
document.addEventListener('DOMContentLoaded', setupFriendNotification);
// ── BannerNotification通用大卡片通知监听 ──────────────────
/**
* 监听 BannerNotification 事件,渲染 chatBanner 大卡片。
*
* 安全说明:
* - BannerNotification 仅由后端可信代码 broadcast前端无法伪造推给他人。
* - 私有频道需鉴权presence 频道需加入房间,均须服务端验证身份。
* - window.chatBanner.show() 即便被人在控制台手动调用,也只影响其自身浏览器,无法影响他人。
* - options.body / options.sub 的 HTML 内容由服务端控制,用户输入始终经过 escapeHtml 处理。
*/
function setupBannerNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupBannerNotification, 500);
return;
}
const myName = window.chatContext.username;
const roomId = window.chatContext.roomId;
// 监听私有用户频道(单独推给某人)
window.Echo.private(`user.${myName}`)
.listen('.BannerNotification', (e) => {
if (e.options && typeof e.options === 'object') {
window.chatBanner.show(e.options);
}
});
// 监听房间频道(推给房间所有人)
if (roomId) {
window.Echo.join(`room.${roomId}`)
.listen('.BannerNotification', (e) => {
if (e.options && typeof e.options === 'object') {
window.chatBanner.show(e.options);
}
});
}
}
document.addEventListener('DOMContentLoaded', setupBannerNotification);
/**
* 显示好友添加居中大卡弹窗(同任命公告风格)。
* 互相好友 → 绿色渐变 + 互为好友文案
* 单向添加 → 蓝绿渐变 + 提示回加 + [ 回加好友] 按钮
*
* @param {string} fromUsername 添加者用户名
* @param {boolean} hasAddedBack 接收方是否已将添加者加为好友
*/
// ═══════════════════════════════════════════════════
// window.chatBanner —— 全局大卡片通知公共组件
// ═══════════════════════════════════════════════════
/**
* 全局大卡片通知组件。
*
* 用法:
* window.chatBanner.show({
* id: 'my-banner', // 可选,防止同 ID 重叠
* icon: '🎉💚🎉', // Emoji 图标
* title: '好友通知', // 小标题
* name: 'lkddi1', // 大名字行(可留空)
* body: '将你加为好友了!', // 主内容(支持 HTML
* sub: '你们现在互为好友 🎊', // 副内容(小字)
* gradient: ['#065f46','#059669','#10b981'], // 渐变颜色
* titleColor: '#a7f3d0', // 小标题颜色
* autoClose: 5000, // 自动关闭 ms0=不关闭
* buttons: [
* { label:'确定', color:'#10b981', onClick(btn, close) { close(); } },
* { label:'取消', color:'rgba(255,255,255,0.15)', onClick(btn, close) { close(); } },
* ],
* });
*
* window.chatBanner.close('my-banner'); // 关闭指定 banner
*/
window.chatBanner = (function() {
// 注入动画样式(全局只注入一次)
function ensureKeyframes() {
if (document.getElementById('appoint-keyframes')) return;
const style = document.createElement('style');
style.id = 'appoint-keyframes';
style.textContent = `
@keyframes appoint-pop {
0% { opacity: 0; transform: translate(-50%,-50%) scale(0.5); }
70% { transform: translate(-50%,-50%) scale(1.05); }
100% { opacity: 1; transform: translate(-50%,-50%) scale(1); }
}
@keyframes appoint-fade-out {
from { opacity: 1; }
to { opacity: 0; transform: translate(-50%,-50%) scale(0.9); }
}
`;
document.head.appendChild(style);
}
/**
* 显示大卡片通知。
*
* @param {Object} opts 选项(见上方注释)
*/
function show(opts = {}) {
ensureKeyframes();
const id = opts.id || 'chat-banner-default';
const gradient = (opts.gradient || ['#4f46e5', '#7c3aed', '#db2777']).join(', ');
const titleColor = opts.titleColor || '#fde68a';
const autoClose = opts.autoClose ?? 5000;
// 移除同 ID 的旧弹窗
const old = document.getElementById(id);
if (old) old.remove();
// 构建按钮 HTML
const hasButtons = opts.buttons && opts.buttons.length > 0;
let buttonsHtml = '';
if (hasButtons) {
buttonsHtml = '<div style="margin-top:18px; display:flex; gap:10px; justify-content:center;">';
opts.buttons.forEach((btn, idx) => {
buttonsHtml += `<button data-banner-btn="${idx}"
style="background:${btn.color || '#10b981'}; color:#fff; border:none; border-radius:8px;
padding:8px 20px; font-size:13px; font-weight:bold; cursor:pointer;
box-shadow:0 4px 12px rgba(0,0,0,0.25);">
${btn.label || '确定'}
</button>`;
});
buttonsHtml += '</div>';
}
const banner = document.createElement('div');
banner.id = id;
banner.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
z-index: 99999; text-align: center;
animation: appoint-pop 0.5s cubic-bezier(0.175,0.885,0.32,1.275);
${hasButtons ? 'pointer-events: auto;' : 'pointer-events: none;'}
`;
banner.innerHTML = `
<div style="background: linear-gradient(135deg, ${gradient});
border-radius: 20px; padding: 28px 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);
min-width: 260px;">
${opts.icon ? `<div style="font-size:40px; margin-bottom:8px;">${opts.icon}</div>` : ''}
${opts.title ? `<div style="color:${titleColor}; font-size:13px; font-weight:bold; letter-spacing:3px; margin-bottom:12px;">
══ ${opts.title} ══
</div>` : ''}
${opts.name ? `<div style="color:white; font-size:22px; font-weight:900; text-shadow:0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(opts.name)}
</div>` : ''}
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${opts.body}</div>` : ''}
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${opts.sub}</div>` : ''}
${buttonsHtml}
<div style="color:rgba(255,255,255,0.35); font-size:11px; margin-top:14px;">
${new Date().toLocaleTimeString('zh-CN')}
</div>
</div>
`;
document.body.appendChild(banner);
// 绑定按钮点击事件
if (hasButtons) {
const closeFn = () => {
banner.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => banner.remove(), 500);
};
opts.buttons.forEach((btn, idx) => {
const el = banner.querySelector(`[data-banner-btn="${idx}"]`);
if (el && btn.onClick) {
el.addEventListener('click', () => btn.onClick(el, closeFn));
} else if (el) {
el.addEventListener('click', closeFn);
}
});
}
// 自动关闭
if (autoClose > 0) {
setTimeout(() => {
if (!document.getElementById(id)) return;
banner.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => banner.remove(), 500);
}, autoClose);
}
}
/**
* 关闭指定 ID 的 banner。
*
* @param {string} id banner 的 DOM ID
*/
function close(id) {
const el = document.getElementById(id || 'chat-banner-default');
if (!el) return;
el.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => el.remove(), 500);
}
return {
show,
close
};
})();
/**
* 好友添加大卡弹窗(使用 chatBanner 公共组件)。
*
* @param {string} fromUsername 添加者用户名
* @param {boolean} hasAddedBack 是否已互相添加
*/
function showFriendBanner(fromUsername, hasAddedBack) {
if (hasAddedBack) {
window.chatBanner.show({
id: 'friend-banner',
icon: '🎉💚🎉',
title: '好友通知',
name: fromUsername,
body: '将你加为好友了!',
sub: '<strong style="color:#a7f3d0;">你们现在互为好友 🎊</strong>',
gradient: ['#065f46', '#059669', '#10b981'],
titleColor: '#a7f3d0',
autoClose: 5000,
});
} else {
window.chatBanner.show({
id: 'friend-banner',
icon: '💚📩',
title: '好友申请',
name: fromUsername,
body: '将你加为好友了!',
sub: '但你还没有回加对方为好友',
gradient: ['#1e3a5f', '#1d4ed8', '#0891b2'],
titleColor: '#bae6fd',
autoClose: 0,
buttons: [{
label: ' 回加好友',
color: '#10b981',
onClick: (btn) => quickFriendAction('add', fromUsername, btn),
},
{
label: '稍后再说',
color: 'rgba(255,255,255,0.15)',
onClick: (btn, close) => close(),
},
],
});
}
}
/**
* 显示好友事件通知浮窗(右下角淡入淡出)。
*
* @param {string} html 通知内容(支持 HTML
* @param {string} color 左边框 / 主题颜色
* @param {object|null} action 可选操作按钮 { label, username, action:'add'|'remove' }
*/
/**
* 聊天区悄悄话内嵌链接的快捷好友操作。
* 由后端生成的 onclick="quickFriendAction('add'/'remove', username, this)" 调用。
*
* @param {string} act 'add' | 'remove'
* @param {string} username 目标用户名
* @param {HTMLElement} el 被点击的 <a> 元素,用于更新显示状态
*/
window.quickFriendAction = async function(act, username, el) {
if (el.dataset.done) return;
el.dataset.done = '1';
const origText = el.textContent;
el.textContent = '处理中…';
el.style.pointerEvents = 'none';
try {
const method = act === 'add' ? 'POST' : 'DELETE';
const url = `/friend/${encodeURIComponent(username)}/${act === 'add' ? 'add' : 'remove'}`;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
const data = await res.json();
if (data.status === 'success') {
el.textContent = act === 'add' ? '✅ 已回加' : '✅ 已移除';
el.style.color = '#16a34a';
el.style.textDecoration = 'none';
} else {
el.textContent = '❌ ' + (data.message || '操作失败');
el.style.color = '#cc4444';
}
} catch (e) {
el.textContent = '❌ 网络错误';
el.style.color = '#cc4444';
delete el.dataset.done;
el.style.pointerEvents = '';
}
};
function showFriendToast(html, color = '#16a34a', action = null) {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed; bottom: 24px; right: 24px; z-index: 999999;
background: #fff; border-left: 4px solid ${color};
border-radius: 8px; padding: 14px 18px; min-width: 260px; max-width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.18);
font-size: 13px; color: #374151; line-height: 1.6;
animation: fdSlideIn .3s ease;
`;
// 操作按钮 HTML
let actionHtml = '';
if (action) {
actionHtml = `
<div style="margin-top:10px;">
<button id="friend-toast-btn-${Date.now()}"
style="background:${color}; color:#fff; border:none; border-radius:5px;
padding:5px 12px; font-size:12px; font-weight:bold; cursor:pointer;">
${action.label}
</button>
</div>`;
}
toast.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<div style="font-weight:bold; margin-bottom:4px; color:${color};">💬 好友通知</div>
<div>${html}</div>
${actionHtml}
</div>
<button onclick="this.closest('div[style]').remove()"
style="background:none; border:none; color:#9ca3af; font-size:18px;
cursor:pointer; line-height:1; margin-left:8px; flex-shrink:0;">×</button>
</div>
`;
document.body.appendChild(toast);
// 绑定操作按钮事件
if (action) {
const btn = toast.querySelector('button[id^="friend-toast-btn"]');
if (btn) {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '处理中…';
try {
const method = action.action === 'add' ? 'POST' : 'DELETE';
const url =
`/friend/${encodeURIComponent(action.username)}/${action.action === 'add' ? 'add' : 'remove'}`;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json'
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
const data = await res.json();
btn.textContent = data.status === 'success' ? '✅ 已完成' : '❌ 失败';
btn.style.background = data.status === 'success' ? '#16a34a' : '#cc4444';
setTimeout(() => toast.remove(), 2000);
} catch (e) {
btn.textContent = '❌ 网络错误';
}
});
}
}
// 5秒后自动消失
setTimeout(() => {
toast.style.transition = 'opacity .5s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 500);
}, 8000);
}
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
window.addEventListener('chat:effect', (e) => {
const type = e.detail?.type;
const target = e.detail?.target_username; // null = 全员otherwise 指定昵称
const myName = window.chatContext?.username;
// null 表示全员,或者 target 匹配自己才播放
if (type && typeof EffectManager !== 'undefined') {
if (!target || target === myName) {
EffectManager.play(type);
}
}
});
/**
* 管理员点击特效按钮,向后端 POST /command/effect
*
* @param {string} type 特效类型fireworks / rain / lightning
*/
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') alert(data.message);
}).catch(err => console.error('特效触发失败:', err));
}
window.triggerEffect = triggerEffect;
// ── 字号设置(持久化到 localStorage─────────────────
/**
* 应用字号到聊天消息窗口,并保存到 localStorage
*
* @param {string|number} size 字号大小px 数字)
*/
function applyFontSize(size) {
const px = parseInt(size, 10);
if (isNaN(px) || px < 10 || px > 30) return;
// 同时应用到公聊窗和包厢窗
const c1 = document.getElementById('chat-messages-container');
const c2 = document.getElementById('chat-messages-container2');
if (c1) c1.style.fontSize = px + 'px';
if (c2) c2.style.fontSize = px + 'px';
// 持久化key 带房间 ID不同房间各自记住
const key = 'chat_font_size';
localStorage.setItem(key, px);
// 同步 select 显示
const sel = document.getElementById('font_size_select');
if (sel) sel.value = String(px);
}
window.applyFontSize = applyFontSize;
// 页面加载后从 localStorage 恢复之前保存的字号
document.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem('chat_font_size');
if (saved) {
applyFontSize(saved);
}
});
// ── 发送消息Enter 发送,防 IME 输入法重复触发)────────
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
let _imeComposing = false;
const _contentInput = document.getElementById('content');
// 中文/日文等 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);
}
});
/**
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)
*/
let _isSending = false; // 发送中防重入标记
async function sendMessage(e) {
if (e) e.preventDefault();
if (_isSending) return; // 上一次还没结束,忽略
_isSending = true;
// 前端禁言检查
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;
return;
}
const form = document.getElementById('chat-form');
const formData = new FormData(form);
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const content = formData.get('content').trim();
if (!content) {
contentInput.focus();
_isSending = false;
return;
}
// 如果发言对象是 AI 小助手,走专用机器人 API
const toUser = formData.get('to_user');
if (toUser === 'AI小班长') {
contentInput.value = '';
contentInput.focus();
_isSending = false;
sendToChatBot(content); // 异步调用,不阻塞全局发送
return;
}
submitBtn.disabled = true;
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 = '';
contentInput.focus();
} else {
alert('发送失败: ' + (data.message || JSON.stringify(data.errors)));
}
} catch (error) {
alert('网络连接错误,消息发送失败!');
console.error(error);
} finally {
submitBtn.disabled = false;
_isSending = false; // 释放发送锁,允许下次发送
}
}
// ── 设置房间公告 ─────────────────────────────────────
async function promptAnnouncement() {
const currentText = document.getElementById('announcement-text')?.textContent?.trim() || '';
const newText = prompt('请输入新的房间公告/祝福语:', currentText);
if (newText === null || newText.trim() === '') return;
try {
const res = await 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()
})
});
const data = await res.json();
if (data.status === 'success') {
const marquee = document.getElementById('announcement-text');
if (marquee) marquee.textContent = newText.trim();
alert('公告已更新!');
} else {
alert(data.message || '更新失败');
}
} catch (e) {
alert('设置公告失败:' + e.message);
}
}
// ── 站长公屏讲话 ─────────────────────────────────────
async function promptAnnounceMessage() {
const content = prompt('请输入公屏讲话内容:');
if (!content || !content.trim()) return;
try {
const res = await 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,
})
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
alert(data.message || '发送失败');
}
} catch (e) {
alert('发送失败:' + e.message);
}
}
// ── 管理员全员清屏 ─────────────────────────────────────
async function adminClearScreen() {
if (!confirm('确定要清除所有人的聊天记录吗?(悄悄话将保留)')) return;
try {
const res = await 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,
})
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
alert(data.message || '清屏失败');
}
} catch (e) {
alert('清屏失败:' + e.message);
}
}
// ── 本地清屏(仅限自己的屏幕)───────────────────────────
function localClearScreen() {
// 清理公聊窗口
const say1 = document.getElementById('chat-messages-container');
if (say1) say1.innerHTML = '';
// 清理包厢窗口
const say2 = document.getElementById('chat-messages-container2');
if (say2) say2.innerHTML = '';
// 将当前最大消息 ID 保存至本地,刷新时只显示大于此 ID 的历史记录
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 toggleAutoScroll() {
autoScroll = !autoScroll;
const cb = document.getElementById('auto_scroll');
if (cb) cb.checked = autoScroll;
const statusEl = document.getElementById('scroll-status');
if (statusEl) statusEl.textContent = autoScroll ? '开' : '关';
}
// ── 退出房间 ─────────────────────────────────────
async function leaveRoom() {
// 标记主动离开pagehide 里不重复发 beacon
window._manualLeave = true;
clearTimeout(visibilityTimer);
try {
await fetch(window.chatContext.leaveUrl, {
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);
}
// ── 关闭/离开页面时自动调用 leave结算勤务时长 ──────────────────────
// 使用 sendBeacon 确保浏览器关闭时请求也能发出(比 fetch 更可靠)
// 注意:用 pagehide 而非 beforeunload避免 Chrome 触发原生「离开网站」确认框
function sendLeaveBeacon() {
if (window._manualLeave) {
return;
} // 主动调用 leaveRoom() 时不重复发
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (!csrfToken || !window.chatContext?.leaveUrl) {
return;
}
const data = new FormData();
data.append('_token', csrfToken);
navigator.sendBeacon(window.chatContext.leaveUrl, data);
}
// pagehide页面关闭/浏览器关闭/刷新均触发,且不会弹原生确认框
window.addEventListener('pagehide', sendLeaveBeacon);
// visibilitychange切换到后台标签超过30秒也结算防止长期挂机不算时长
let visibilityTimer = null;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 切到后台30秒后结算
visibilityTimer = setTimeout(sendLeaveBeacon, 30 * 1000);
} else {
// 切回来,取消结算
clearTimeout(visibilityTimer);
visibilityTimer = null;
}
});
// ── 掉线检测计数器 ──
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) {
alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。');
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 levelTitle = d.title || '普通会员';
let levelInfo = '';
if (d.is_max_level) {
levelInfo = `级别(${d.user_level});经验(${d.exp_num});金币(${d.jjb}枚);已满级。`;
} else {
levelInfo = `级别(${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.innerHTML =
`<span style="color: green;">【${levelTitle}存点】您的最新情况:${levelInfo} ${gainInfo}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(detailDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
return;
}
}
} catch (e) {
console.error('存点失败', e);
heartbeatFailCount++;
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
alert('⚠️ 与服务器的连接已断开,请检查网络后重新登录。');
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);
// ── 头像选择器(原版 fw.asp 功能)───────────────
let avatarPickerLoaded = false;
/**
* 打开头像选择弹窗
*/
function openAvatarPicker() {
const modal = document.getElementById('avatar-picker-modal');
modal.style.display = 'flex';
if (!avatarPickerLoaded) {
loadHeadfaces();
avatarPickerLoaded = true;
}
}
/**
* 关闭头像选择弹窗
*/
function closeAvatarPicker() {
document.getElementById('avatar-picker-modal').style.display = 'none';
}
/**
* 加载头像列表
*/
async function loadHeadfaces() {
const grid = document.getElementById('avatar-grid');
grid.innerHTML = '<div style="text-align:center;padding:20px;color:#999;">加载中...</div>';
try {
const res = await fetch('/headface/list');
const data = await res.json();
grid.innerHTML = '';
data.headfaces.forEach(file => {
const img = document.createElement('img');
img.src = '/images/headface/' + file;
img.className = 'avatar-option';
img.title = file;
img.dataset.file = file;
img.onerror = () => img.style.display = 'none';
img.onclick = () => selectAvatar(file, img);
grid.appendChild(img);
});
} catch (e) {
grid.innerHTML = '<div style="text-align:center;padding:20px;color:red;">加载失败</div>';
}
}
/**
* 选中一个头像
*/
function selectAvatar(file, imgEl) {
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
imgEl.classList.add('selected');
document.getElementById('avatar-preview').src = '/images/headface/' + file;
document.getElementById('avatar-selected-name').textContent = file;
document.getElementById('avatar-save-btn').disabled = false;
document.getElementById('avatar-save-btn').dataset.file = file;
}
/**
* 保存选中的头像
*/
async function saveAvatar() {
const btn = document.getElementById('avatar-save-btn');
const file = btn.dataset.file;
if (!file) return;
btn.disabled = true;
btn.textContent = '保存中...';
try {
const res = await fetch('/headface/change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({
headface: file
})
});
const data = await res.json();
if (data.status === 'success') {
alert('头像修改成功!');
const myName = window.chatContext.username;
if (onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
renderUserList();
closeAvatarPicker();
} else {
alert(data.message || '修改失败');
}
} catch (e) {
alert('网络错误');
}
btn.disabled = false;
btn.textContent = '确定更换';
}
// ── 钓鱼小游戏(复刻原版 diaoyu/ 功能)─────────────
let fishingTimer = null;
let fishingReelTimeout = null;
/**
* 开始钓鱼 — 调用抛竿 API花费金币显示等待动画
*/
async function startFishing() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
try {
const res = await fetch(window.chatContext.fishCastUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
alert(data.message || '钓鱼失败');
btn.disabled = false;
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 castDiv = document.createElement('div');
castDiv.className = 'msg-line';
castDiv.innerHTML =
`<span style="color: #2563eb; font-weight: bold;">🎣【钓鱼】</span>${data.message}<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(castDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
// 等待鱼上钩(后端返回的随机等待秒数)
btn.textContent = '🎣 等待中...';
fishingTimer = setTimeout(() => {
// 鱼上钩了!
const hookDiv = document.createElement('div');
hookDiv.className = 'msg-line';
hookDiv.innerHTML =
'<span style="color: #d97706; font-weight: bold; font-size: 14px;">🐟 鱼上钩了!快点击 <span onclick="reelFish()" style="text-decoration:underline; cursor:pointer; color:#dc2626;">[拉竿]</span> 按钮!</span>';
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
btn.textContent = '🎣 拉竿!';
btn.disabled = false;
btn.onclick = reelFish;
// 15 秒内不拉竿,鱼跑掉
fishingReelTimeout = setTimeout(() => {
const missDiv = document.createElement('div');
missDiv.className = 'msg-line';
missDiv.innerHTML =
'<span style="color: #999;">💨 你反应太慢了,鱼跑掉了...</span>';
container2.appendChild(missDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
resetFishingBtn();
}, 15000);
}, data.wait_time * 1000);
} catch (e) {
alert('网络错误:' + e.message);
btn.disabled = false;
}
}
/**
* 拉竿 — 调用收竿 API获取随机结果
*/
async function reelFish() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 拉竿中...';
// 取消跑鱼计时器
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
try {
const res = await fetch(window.chatContext.fishReelUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
if (res.ok && data.status === 'success') {
const r = data.result;
const color = r.exp >= 0 ? '#16a34a' : '#dc2626';
const resultDiv = document.createElement('div');
resultDiv.className = 'msg-line';
resultDiv.innerHTML =
`<span style="color: ${color}; font-weight: bold;">${r.emoji}【钓鱼结果】</span>${r.message}` +
` <span style="color: #666; font-size: 11px;">(当前经验:${data.exp_num} 金币:${data.jjb}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(resultDiv);
} else {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML =
`<span style="color: red;">【钓鱼】${data.message || '操作失败'}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(errDiv);
}
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
alert('网络错误:' + e.message);
}
resetFishingBtn();
}
/**
* 重置钓鱼按钮状态
*/
function resetFishingBtn() {
const btn = document.getElementById('fishing-btn');
btn.textContent = '🎣 钓鱼';
btn.disabled = false;
btn.onclick = startFishing;
fishingTimer = null;
fishingReelTimeout = null;
}
// ── AI 聊天机器人 ──────────────────────────────────
let chatBotSending = false;
/**
* 发送消息给 AI 机器人
* 先在包厢窗口显示用户消息,再调用 API 获取回复
*/
async function sendToChatBot(content) {
if (chatBotSending) {
alert('AI 正在思考中,请稍候...');
return;
}
chatBotSending = true;
// 显示"思考中"提示
// 延迟显示"思考中",让广播消息先到达
const thinkDiv = document.createElement('div');
thinkDiv.className = 'msg-line';
thinkDiv.innerHTML = '<span style="color: #16a34a;">🤖 <b>AI小班长</b> 正在思考中...</span>';
setTimeout(() => {
container2.appendChild(thinkDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}, 500);
try {
const res = await fetch(window.chatContext.chatBotUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
message: content,
room_id: window.chatContext.roomId
})
});
const data = await res.json();
// 移除"思考中"提示(消息已通过广播显示)
thinkDiv.remove();
if (!res.ok || data.status !== 'success') {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML =
`<span style="color: #dc2626;">🤖【AI小班长】${data.message || '回复失败,请稍后重试'}</span>`;
container.appendChild(errDiv);
}
} catch (e) {
thinkDiv.remove();
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML = '<span style="color: #dc2626;">🤖【AI小班长】网络连接错误请稍后重试</span>';
container.appendChild(errDiv);
}
chatBotSending = false;
scrollToBottom();
}
/**
* 清除与 AI 小助手的对话上下文
*/
async function clearChatBotContext() {
try {
const res = await fetch(window.chatContext.chatBotClearUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
const data = await res.json();
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML = '<span style="color: #16a34a;">🤖【系统】' + (data.message || '对话已重置') + '</span>';
container2.appendChild(sysDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
alert('清除失败:' + e.message);
}
}
/**
* HTML 转义函数,防止 XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ══════════════════════════════════════════
// 任命公告:复用现有礼花特效 + 隆重弹窗
// ══════════════════════════════════════════
/**
* 显示任命公告弹窗居中5 秒后淡出)
*/
/**
* 显示任命公告弹窗(改用 chatBanner 公共组件)。
*
* @param {Object} data 任命数据type, target_username, position_icon, position_name, department_name, operator_name
*/
function showAppointmentBanner(data) {
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
const isRevoke = data.type === 'revoke';
if (isRevoke) {
window.chatBanner.show({
id: 'appointment-banner',
icon: '📋',
title: '职务撤销',
name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`,
body: `<strong style="color:#f3f4f6;">${dept}${escapeHtml(data.position_name)}</strong> 职务已被撤销`,
sub: `由 ${escapeHtml(data.operator_name)} 执行`,
gradient: ['#374151', '#4b5563', '#6b7280'],
titleColor: '#d1d5db',
autoClose: 4500,
});
} else {
window.chatBanner.show({
id: 'appointment-banner',
icon: '🎊🎖️🎊',
title: '任命公告',
name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`,
body: `荣任 <strong style="color:#fde68a;">${dept}${escapeHtml(data.position_name)}</strong>`,
sub: `由 ${escapeHtml(data.operator_name)} 任命`,
gradient: ['#4f46e5', '#7c3aed', '#db2777'],
titleColor: '#fde68a',
autoClose: 4500,
});
}
}
/**
* 监听任命公告事件:根据 type 区分任命(礼花+紫色弹窗)和撤销(灰色弹窗)
*/
window.addEventListener('chat:appointment-announced', (e) => {
const data = e.detail;
const isRevoke = data.type === 'revoke';
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
// ── 任命才有礼花 ──
if (!isRevoke && typeof EffectManager !== 'undefined') {
EffectManager.play('fireworks');
}
showAppointmentBanner(data);
// ── 聊天区系统消息:操作者/被操作者 → 私聊面板;其余人 → 公屏 ──
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 myName = window.chatContext?.username ?? '';
const isInvolved = myName === data.operator_name || myName === data.target_username;
// 随机鼓励语库
const appointPhrases = [
'望再接再厉,大展宏图,为大家服务!',
'期待在任期间带领大家更上一层楼!',
'众望所归,任重道远,加油!',
'新官上任,一展风采,前程似锦!',
'相信你能胜任,期待你的精彩表现!',
];
const revokePhrases = [
'感谢在任期间的辛勤付出,辛苦了!',
'江湖路长,愿前程似锦,未来可期!',
'感谢您为大家的奉献,一路顺风!',
'在任一场,情谊长存,感谢付出!',
'相信以后还有更多精彩,继续加油!',
];
const randomPhrase = isRevoke ?
revokePhrases[Math.floor(Math.random() * revokePhrases.length)] :
appointPhrases[Math.floor(Math.random() * appointPhrases.length)];
// 构建消息 DOM内容相同分配到不同面板
function buildSysMsg() {
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
if (isRevoke) {
sysDiv.style.cssText =
'background:#f3f4f6; border-left:3px solid #9ca3af; border-radius:4px; padding:4px 10px; margin:2px 0;';
sysDiv.innerHTML =
`<span style="color:#6b7280;">📋 </span>` +
`<span style="color:#374151;"><b>${escapeHtml(data.target_username)}</b> 的 ${escapeHtml(data.position_icon)} ${dept}${escapeHtml(data.position_name)} 职务已被 <b>${escapeHtml(data.operator_name)}</b> 撤销。${randomPhrase}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
} else {
sysDiv.style.cssText =
'background:#f5f3ff; border-left:3px solid #7c3aed; border-radius:4px; padding:4px 10px; margin:2px 0;';
sysDiv.innerHTML =
`<span style="color:#7c3aed;">🎖️ </span>` +
`<span style="color:#3730a3;">恭喜 <b>${escapeHtml(data.target_username)}</b> 荣任 ${escapeHtml(data.position_icon)} ${dept}<b>${escapeHtml(data.position_name)}</b>,由 <b>${escapeHtml(data.operator_name)}</b> 任命。${randomPhrase}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
}
return sysDiv;
}
const say1 = document.getElementById('chat-messages-container');
const say2 = document.getElementById('chat-messages-container2');
if (isInvolved) {
// 操作者 / 被操作者:消息进私聊面板(包厢窗口)
if (say2) {
say2.appendChild(buildSysMsg());
say2.scrollTop = say2.scrollHeight;
}
} else {
// 其他人:消息进公屏
if (say1) {
say1.appendChild(buildSysMsg());
say1.scrollTop = say1.scrollHeight;
}
}
});
</script>