Files
chatroom/resources/views/chat/partials/scripts.blade.php
lkddi 6fa42b90d5 功能:站长礼包系统(金币/经验双类型)+ 后台用户编辑权限收紧(仅 id=1 超管)
新增功能:
- 礼包系统:superlevel 站长可发 888 数量 10 份礼包,支持金币/经验双类型
- 发包前三按钮选择(金币礼包 / 经验礼包 / 取消),使用 chatBanner 弹窗
- 聊天室系统公告含「立即抢包」按钮,金币红色/经验紫色配色区分
- WebSocket 实时推送红包弹窗卡片至所有在线用户
- Redis LPOP 原子分发 + 数据库 unique 约束防重领,并发安全
- 弹窗打开自动拉取服务端最新状态(剩余数量/已领/过期实时刷新)
- 新增 GET /red-packet/{id}/status 状态查询接口
- 新增 CurrencySource::RED_PACKET_RECV / RED_PACKET_RECV_EXP 枚举
安全加固:
- 后台用户编辑/强杀按钮仅 id=1 超管可见(前端隐藏 + 后端 403 双重拦截)
2026-03-01 22:20:54 +08:00

2817 lines
116 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);
// 若消息携带 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;
});
// ── 管理员全员清屏事件(等待 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;
const myId = window.chatContext.userId;
window.Echo.private(`user.${myId}`)
.listen('.FriendAdded', (e) => {
// 用居中大卡弹窗通知(有无互相好友显示不同文案和按钮)
showFriendBanner(e.from_username, e.has_added_back);
})
.listen('.FriendRemoved', (e) => {
if (e.had_added_back) {
// 之前是互相好友,现在对方删除了我 → 提示可以同步删除
window.chatToast.show({
title: '好友通知',
message: `<b>${e.from_username}</b> 已将你从好友列表移除。<br><span style="color:#6b7280; font-size:12px;">你的好友列表中仍保留对方,可点击同步移除。</span>`,
icon: '👥',
color: '#6b7280',
duration: 10000,
action: {
label: `🗑️ 同步移除 ${e.from_username}`,
onClick: async () => {
const url = `/friend/${encodeURIComponent(e.from_username)}/remove`;
const csrf = document.querySelector('meta[name="csrf-token"]')
?.content ?? '';
await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json'
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
}
},
});
} else {
window.chatToast.show({
title: '好友通知',
message: `<b>${e.from_username}</b> 已将你从他的好友列表移除。`,
icon: '👥',
color: '#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 myId = window.chatContext.userId;
const roomId = window.chatContext.roomId;
// 监听私有用户频道(单独推给某人,用数字 ID 避免中文名频道非法)
window.Echo.private(`user.${myId}`)
.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();
// 大卡片弹出时播放叮咚通知音
if (window.chatSound) window.chatSound.ding();
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: async (btn, close) => {
await quickFriendAction('add', fromUsername, btn);
// 成功后(按钮显示 ✅)等 1.5 秒关闭大卡片
if (btn.textContent.startsWith('✅')) {
setTimeout(close, 1500);
}
},
},
{
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 = '';
}
};
// showFriendToast 已迁移至 window.chatToasttoast-notification.blade.php
// 保留空函数作向后兼容(移除时搜索 showFriendToast 确认无残余调用)
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
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') window.chatDialog.alert(data.message, '操作失败', '#cc4444');
}).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);
}
// 恢复禁音复选框状态
const muted = localStorage.getItem('chat_sound_muted') === '1';
const muteChk = document.getElementById('sound_muted');
if (muteChk) muteChk.checked = muted;
});
// ── 特效禁音开关 ─────────────────────────────────────────────────
/**
* 切换特效音效的静音状态,持久化到 localStorage。
* 开启禁音后立即停止当前正在播放的音效。
*
* @param {boolean} muted true = 禁音false = 开启声音
*/
function toggleSoundMute(muted) {
localStorage.setItem('chat_sound_muted', muted ? '1' : '0');
if (muted && typeof EffectSounds !== 'undefined') {
EffectSounds.stop(); // 立即停止当前音效
}
}
window.toggleSoundMute = toggleSoundMute;
// ── 发送消息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 {
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; // 释放发送锁,允许下次发送
}
}
// ── 设置房间公告 ─────────────────────────────────────
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();
window.chatDialog.alert('公告已更新!', '提示', '#16a34a');
} else {
window.chatDialog.alert(data.message || '更新失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('设置公告失败:' + e.message, '操作失败', '#cc4444');
}
}
// ── 站长公屏讲话 ─────────────────────────────────────
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') {
window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
}
}
// ── 管理员全员清屏 ─────────────────────────────────────
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') {
window.chatDialog.alert(data.message || '清屏失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('清屏失败:' + e.message, '操作失败', '#cc4444');
}
}
// ── 本地清屏(仅限自己的屏幕)───────────────────────────
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) {
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 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) {
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);
// ── 头像选择器(原版 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') {
window.chatDialog.alert('头像修改成功!', '提示', '#16a34a');
const myName = window.chatContext.username;
if (onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
renderUserList();
closeAvatarPicker();
} else {
window.chatDialog.alert(data.message || '修改失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('网络错误', '网络异常', '#cc4444');
}
btn.disabled = false;
btn.textContent = '确定更换';
}
// ── 钓鱼小游戏(随机浮漂版)─────────────────────────
let fishingTimer = null;
let fishingReelTimeout = null;
let _fishToken = null; // 当次钓鱼的 token
let _autoFishing = false; // 是否处于自动钓鱼循环中
let _autoFishCdTimer = null; // 自动钓鱼冷却计时器
let _autoFishCdCountdown = null; // 冷却倒计时 interval
/**
* 创建浮漂 DOM 元素(绝对定位在聊天框上层)
* @param {number} x 水平百分比 0-100
* @param {number} y 垂直百分比 0-100
* @returns {HTMLElement}
*/
function createBobber(x, y) {
const el = document.createElement('div');
el.id = 'fishing-bobber';
el.style.cssText = `
position: fixed;
left: ${x}vw;
top: ${y}vh;
font-size: 28px;
cursor: pointer;
z-index: 9999;
animation: bobberFloat 1.2s ease-in-out infinite;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4));
user-select: none;
transition: transform 0.3s;
`;
el.textContent = '🪝';
el.title = '鱼上钩了!快点击!';
// 注入动画
if (!document.getElementById('bobber-style')) {
const style = document.createElement('style');
style.id = 'bobber-style';
style.textContent = `
@keyframes bobberFloat {
0%,100% { transform: translateY(0) rotate(-8deg); }
50% { transform: translateY(-10px) rotate(8deg); }
}
@keyframes bobberSink {
0% { transform: translateY(0) scale(1); opacity:1; }
30% { transform: translateY(12px) scale(1.3); opacity:1; }
100% { transform: translateY(40px) scale(0.5); opacity:0; }
}
@keyframes bobberPulse {
0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.6); }
50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); }
}
#fishing-bobber.sinking {
animation: bobberSink 1.5s forwards !important;
}
`;
document.head.appendChild(style);
}
return el;
}
/** 移除浮漂 */
function removeBobber() {
const el = document.getElementById('fishing-bobber');
if (el) el.remove();
}
/**
* 开始钓鱼:调用抛竿 API随机显示浮漂位置
*/
async function startFishing() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 抛竿中...';
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') {
window.chatDialog.alert(data.message || '钓鱼失败', '操作失败', '#cc4444');
btn.disabled = false;
btn.textContent = '🎣 钓鱼';
return;
}
// 保存本次 token收竿时提交
_fishToken = data.token;
_autoFishing = !!data.auto_fishing; // 持有自动钓鱼卡则开启循环模式
// 聊天框提示
const castDiv = document.createElement('div');
castDiv.className = 'msg-line';
const timeStr = new Date().toLocaleTimeString('zh-CN', {
hour12: false
});
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 = '🎣 等待中...';
// 创建浮漂(浮漂在随机位置)
const bobber = createBobber(data.bobber_x, data.bobber_y);
document.body.appendChild(bobber);
// 等待 wait_time 秒后浮漂「下沉」
fishingTimer = setTimeout(() => {
// 播放下沉动画
bobber.classList.add('sinking');
bobber.textContent = '🐟';
const hookDiv = document.createElement('div');
hookDiv.className = 'msg-line';
if (data.auto_fishing) {
// 自动钓鱼卡:在动画结束后自动收竿
hookDiv.innerHTML =
`<span style="color:#7c3aed;font-weight:bold;">🎣 自动钓鱼卡生效!自动收竿中... <span style="font-size:10px;opacity:0.7">(剩余${data.auto_fishing_minutes_left}分钟)</span></span>`;
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
// 500ms 后自动收竿(等动画)
fishingReelTimeout = setTimeout(() => {
removeBobber();
reelFish();
}, 1800);
} else {
// 手动模式:玩家需在 8 秒内点击浮漂
hookDiv.innerHTML =
`<span style="color:#d97706;font-weight:bold;font-size:14px;">🐟 鱼上钩了!快点击屏幕上的浮漂!</span>`;
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
btn.textContent = '🎣 点击浮漂!';
// 浮漂点击事件
bobber.onclick = () => {
removeBobber();
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
reelFish();
};
// 8 秒内不点击 → 鱼跑了token 过期服务端也会拒绝)
fishingReelTimeout = setTimeout(() => {
removeBobber();
_fishToken = null;
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();
}, 8000);
}
}, data.wait_time * 1000);
} catch (e) {
window.chatDialog.alert('网络错误:' + e.message, '网络异常', '#cc4444');
removeBobber();
btn.disabled = false;
btn.textContent = '🎣 钓鱼';
}
}
/**
* 收竿 — 提交 token 到后端,获取随机结果
*/
async function reelFish() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 拉竿中...';
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
const token = _fishToken;
_fishToken = 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',
'Content-Type': 'application/json'
},
body: JSON.stringify({
token
})
});
const data = await res.json();
const timeStr = new Date().toLocaleTimeString('zh-CN', {
hour12: false
});
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);
// 自动钓鱼卡循环:等冷却时间后自动再次抛竿
if (_autoFishing) {
const cooldown = data.cooldown_seconds || 300;
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = `⏳ 冷却 ${cooldown}s`;
btn.onclick = null;
// 显示停止按钮
_showAutoFishStopBtn(cooldown);
// 倒计时更新文字
let remaining = cooldown;
_autoFishCdCountdown = setInterval(() => {
remaining--;
const b = document.getElementById('fishing-btn');
if (b) b.textContent = `⏳ 冷却 ${remaining}s`;
if (remaining <= 0) {
clearInterval(_autoFishCdCountdown);
_autoFishCdCountdown = null;
}
}, 1000);
// 冷却结束后自动抛竿
_autoFishCdTimer = setTimeout(() => {
_autoFishCdTimer = null;
_hideAutoFishStopBtn();
if (_autoFishing) startFishing(); // 仍未停止 → 继续
}, cooldown * 1000);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
return; // 不走 resetFishingBtn
}
} 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);
_autoFishing = false; // 出错时停止循环
}
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
window.chatDialog.alert('网络错误:' + e.message, '网络异常', '#cc4444');
_autoFishing = false;
}
resetFishingBtn();
}
/**
* 显示「停止自动钓鱼」悬浮按钮
* @param {number} cooldown 冷却秒数(用于倒计时提示)
*/
function _showAutoFishStopBtn(cooldown) {
if (document.getElementById('auto-fish-stop-btn')) return;
const stopBtn = document.createElement('button');
stopBtn.id = 'auto-fish-stop-btn';
stopBtn.innerHTML = '🛑 停止自动钓鱼';
stopBtn.style.cssText = `
position: fixed;
bottom: 80px;
right: 20px;
z-index: 10000;
background: linear-gradient(135deg, #dc2626, #b91c1c);
color: #fff;
border: none;
border-radius: 20px;
padding: 8px 18px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 4px 12px rgba(220,38,38,0.4);
animation: autoFishBtnPulse 1.8s ease-in-out infinite;
`;
if (!document.getElementById('auto-fish-stop-style')) {
const s = document.createElement('style');
s.id = 'auto-fish-stop-style';
s.textContent = `@keyframes autoFishBtnPulse {
0%,100% { transform: scale(1); }
50% { transform: scale(1.05); }
}`;
document.head.appendChild(s);
}
stopBtn.onclick = stopAutoFishing;
document.body.appendChild(stopBtn);
}
/** 隐藏停止按钮 */
function _hideAutoFishStopBtn() {
const el = document.getElementById('auto-fish-stop-btn');
if (el) el.remove();
}
/**
* 手动停止自动钓鱼循环
*/
function stopAutoFishing() {
_autoFishing = false;
if (_autoFishCdTimer) {
clearTimeout(_autoFishCdTimer);
_autoFishCdTimer = null;
}
if (_autoFishCdCountdown) {
clearInterval(_autoFishCdCountdown);
_autoFishCdCountdown = null;
}
_hideAutoFishStopBtn();
const noticeDiv = document.createElement('div');
noticeDiv.className = 'msg-line';
noticeDiv.innerHTML = '<span style="color:#6b7280;">🛑 已停止自动钓鱼。</span>';
container2.appendChild(noticeDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
resetFishingBtn();
}
/**
* 重置钓鱼按钮状态(停止自动循环后调用)
*/
function resetFishingBtn() {
_autoFishing = false;
_hideAutoFishStopBtn();
if (_autoFishCdTimer) {
clearTimeout(_autoFishCdTimer);
_autoFishCdTimer = null;
}
if (_autoFishCdCountdown) {
clearInterval(_autoFishCdCountdown);
_autoFishCdCountdown = null;
}
const btn = document.getElementById('fishing-btn');
btn.textContent = '🎣 钓鱼';
btn.disabled = false;
btn.onclick = startFishing;
fishingTimer = null;
fishingReelTimeout = null;
removeBobber();
}
// ── AI 聊天机器人 ──────────────────────────────────
let chatBotSending = false;
/**
* 发送消息给 AI 机器人
* 先在包厢窗口显示用户消息,再调用 API 获取回复
*/
async function sendToChatBot(content) {
if (chatBotSending) {
window.chatDialog.alert('AI 正在思考中,请稍候...', '提示', '#336699');
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) {
window.chatDialog.alert('清除失败:' + e.message, '操作失败', '#cc4444');
}
}
/**
* 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>
{{-- ═══════════════════════════════════════ --}}
{{-- 礼包红包弹窗HTML + CSS + 交互脚本) --}}
{{-- ═══════════════════════════════════════ --}}
<style>
/* 红包弹窗遮罩 */
#red-packet-modal {
display: none;
position: fixed;
inset: 0;
z-index: 10500;
background: rgba(0, 0, 0, 0.6);
justify-content: center;
align-items: center;
animation: rpFadeIn 0.25s ease;
}
@keyframes rpFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 红包卡片主体 */
#red-packet-card {
width: 300px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(220, 38, 38, 0.4);
animation: rpCardIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
}
@keyframes rpCardIn {
from {
transform: scale(0.7) translateY(40px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
/* 红包顶部区 */
#rp-header {
background: linear-gradient(160deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%);
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
#rp-header::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(255, 100, 0, 0.3) 0%, transparent 70%);
pointer-events: none;
}
.rp-emoji {
font-size: 52px;
display: block;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
animation: rpBounce 1.5s ease-in-out infinite;
}
@keyframes rpBounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.rp-sender {
color: #fde68a;
font-size: 13px;
margin-top: 8px;
font-weight: bold;
}
.rp-title {
color: #fff;
font-size: 18px;
font-weight: bold;
margin-top: 4px;
letter-spacing: 1px;
}
.rp-amount {
color: #fde68a;
font-size: 28px;
font-weight: bold;
margin-top: 6px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.rp-amount small {
font-size: 14px;
opacity: 0.85;
}
/* 红包底部区 */
#rp-body {
background: #fff8f0;
padding: 16px 20px;
}
.rp-info-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #92400e;
margin-bottom: 8px;
}
/* 倒计时条 */
#rp-timer-bar-wrap {
background: #fee2e2;
border-radius: 4px;
height: 6px;
overflow: hidden;
margin-bottom: 14px;
}
#rp-timer-bar {
height: 100%;
background: linear-gradient(90deg, #dc2626, #f97316);
border-radius: 4px;
transition: width 1s linear;
}
/* 领取按钮 */
#rp-claim-btn {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #dc2626, #ea580c);
color: #fff;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 10px;
cursor: pointer;
letter-spacing: 1px;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
transition: opacity .2s, transform .15s;
}
#rp-claim-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
#rp-claim-btn:active {
transform: translateY(0);
}
#rp-claim-btn:disabled {
background: #9ca3af;
box-shadow: none;
cursor: not-allowed;
transform: none;
}
/* 已领取名单 */
#rp-claims-list {
margin-top: 12px;
max-height: 100px;
overflow-y: auto;
border-top: 1px dashed #fca5a5;
padding-top: 8px;
}
.rp-claim-item {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #555;
padding: 2px 0;
}
.rp-claim-item span:last-child {
color: #dc2626;
font-weight: bold;
}
/* 关闭按钮 */
#rp-close-btn {
position: absolute;
top: 10px;
right: 12px;
color: rgba(255, 255, 255, 0.7);
font-size: 20px;
cursor: pointer;
line-height: 1;
}
#rp-close-btn:hover {
color: #fff;
}
/* 状态提示 */
#rp-status-msg {
font-size: 12px;
text-align: center;
margin-top: 8px;
min-height: 16px;
color: #16a34a;
font-weight: bold;
}
</style>
{{-- 红包弹窗 DOM --}}
<div id="red-packet-modal">
<div id="red-packet-card">
{{-- 顶部标题区 --}}
<div id="rp-header">
<span id="rp-close-btn" onclick="closeRedPacketModal()"></span>
<span class="rp-emoji">🧧</span>
<div class="rp-sender" id="rp-sender-name">xxx 的礼包</div>
<div class="rp-title">聊天室专属礼包</div>
<div class="rp-amount"><small>总计 </small><span id="rp-total-amount">888</span><small> 金币</small></div>
</div>
{{-- 底部操作区 --}}
<div id="rp-body">
<div class="rp-info-row">
<span>剩余份数:<b id="rp-remaining">10</b> / <span id="rp-total-count">10</span> </span>
<span>倒计时:<b id="rp-countdown">120</b>s</span>
</div>
<div id="rp-timer-bar-wrap">
<div id="rp-timer-bar" style="width:100%;"></div>
</div>
<button id="rp-claim-btn" onclick="claimRedPacket()">🧧 立即抢红包</button>
<div id="rp-status-msg"></div>
{{-- 领取名单 --}}
<div id="rp-claims-list" style="display:none;">
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
<div id="rp-claims-items"></div>
</div>
</div>
</div>
</div>
<script>
/**
* 礼包红包前端交互模块
*
* 功能:
* 1. sendRedPacket() — superlevel 点击「礼包」按钮后确认发包
* 2. showRedPacketModal() — 收到 RedPacketSent 事件后弹出红包卡片
* 3. claimRedPacket() — 用户点击「立即抢红包」
* 4. closeRedPacketModal() — 关闭红包弹窗
* 5. WebSocket 监听 — 监听 red-packet.sent 广播事件
*/
(function() {
'use strict';
// 当前红包状态
let _rpEnvelopeId = null; // 当前红包 ID
let _rpExpireAt = null; // 过期时间戳ms
let _rpTotalSeconds = 120; // 总倒计时秒数
let _rpTimer = null; // 倒计时定时器
let _rpClaimed = false; // 本次会话是否已领取
// ── 发包确认 ───────────────────────────────────────
/**
* superlevel 点击「礼包」按鈕,弹出 chatBanner 三按鈕选择类型后发包。
*/
window.sendRedPacket = function() {
window.chatBanner.show({
icon: '🧧',
title: '发出礼包',
name: '选择礼包类型',
body: '将发出 <b>888</b> 数量共 <b>10</b> 份的礼包,系统凭空发放,房间成员先到先得!',
gradient: ['#991b1b', '#dc2626', '#ea580c'],
titleColor: '#fde68a',
autoClose: 0,
buttons: [{
label: '🪙 金币礼包',
color: '#d97706',
onClick(btn, close) {
close();
doSendRedPacket('gold');
},
},
{
label: '✨ 经验礼包',
color: '#7c3aed',
onClick(btn, close) {
close();
doSendRedPacket('exp');
},
},
{
label: '取消',
color: 'rgba(255,255,255,0.15)',
onClick(btn, close) {
close();
},
},
],
});
};
/**
* 实际发包请求(由 chatBanner 按鈕回调触发)。
*
* @param {'gold'|'exp'} type 货币类型
*/
async function doSendRedPacket(type) {
const btn = document.getElementById('red-packet-btn');
if (btn) {
btn.disabled = true;
btn.textContent = '发送中…';
}
try {
const res = await fetch('/command/red-packet/send', {
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,
type
}),
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
await window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
}
// 成功后 WebSocket 广播 RedPacketSent前端自动弹出红包卡片
} catch (e) {
await window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
} finally {
setTimeout(() => {
if (btn) {
btn.disabled = false;
btn.innerHTML = '🧧 礼包';
}
}, 3000);
}
}
/**
* 展示红包弹窗,并启动倒计时。
*
* @param {number} envelopeId 红包 ID
* @param {string} senderUsername 发包人用户名
* @param {number} totalAmount 总数量
* @param {number} totalCount 总份数
* @param {number} expireSeconds 有效秒数
* @param {'gold'|'exp'} type 货币类型
*/
window.showRedPacketModal = function(envelopeId, senderUsername, totalAmount, totalCount, expireSeconds,
type) {
_rpEnvelopeId = envelopeId;
_rpClaimed = false;
_rpTotalSeconds = expireSeconds;
_rpExpireAt = Date.now() + expireSeconds * 1000;
// 根据类型调整配色和标签
const isExp = (type === 'exp');
const typeIcon = isExp ? '✨' : '🪙';
const typeName = isExp ? '经验' : '金币';
const headerBg = isExp ?
'linear-gradient(160deg,#6d28d9 0%,#5b21b6 50%,#4c1d95 100%)' :
'linear-gradient(160deg,#dc2626 0%,#b91c1c 50%,#991b1b 100%)';
const claimBg = isExp ?
'linear-gradient(135deg,#7c3aed,#4f46e5)' :
'linear-gradient(135deg,#dc2626,#ea580c)';
// 应用配色
document.getElementById('rp-header').style.background = headerBg;
document.getElementById('rp-claim-btn').style.background = claimBg;
// 填入数据
document.getElementById('rp-sender-name').textContent = senderUsername + ' 的礼包';
document.getElementById('rp-total-amount').textContent = totalAmount;
document.getElementById('rp-total-count').textContent = totalCount;
document.getElementById('rp-remaining').textContent = totalCount;
document.getElementById('rp-countdown').textContent = expireSeconds;
document.getElementById('rp-timer-bar').style.width = '100%';
document.getElementById('rp-status-msg').textContent = '';
document.getElementById('rp-claims-list').style.display = 'none';
document.getElementById('rp-claims-items').innerHTML = '';
// 更新卡片标题信息
document.querySelector('.rp-emoji').textContent = typeIcon;
document.querySelector('.rp-title').textContent = typeName + '礼包';
const amountEl = document.getElementById('rp-total-amount');
amountEl.nextSibling.textContent = ' ' + typeName; // small 标签
const claimBtn = document.getElementById('rp-claim-btn');
claimBtn.disabled = false;
claimBtn.textContent = typeIcon + ' 立即抢包';
// 显示弹窗
document.getElementById('red-packet-modal').style.display = 'flex';
// 启动倒计时
clearInterval(_rpTimer);
_rpTimer = setInterval(() => {
const remaining = Math.max(0, Math.ceil((_rpExpireAt - Date.now()) / 1000));
document.getElementById('rp-countdown').textContent = remaining;
document.getElementById('rp-timer-bar').style.width =
(remaining / _rpTotalSeconds * 100) + '%';
if (remaining <= 0) {
clearInterval(_rpTimer);
document.getElementById('rp-claim-btn').disabled = true;
document.getElementById('rp-claim-btn').textContent = '礼包已过期';
document.getElementById('rp-status-msg').style.color = '#9ca3af';
document.getElementById('rp-status-msg').textContent = '红包已过期。';
}
}, 1000);
// 异步拉取服务端最新状态(实时刷新剩余份数)
fetch(`/red-packet/${envelopeId}/status`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') return;
// 更新剩余份数显示
document.getElementById('rp-remaining').textContent = data.remaining_count;
const claimBtn = document.getElementById('rp-claim-btn');
const statusMsg = document.getElementById('rp-status-msg');
// 若已过期
if (data.is_expired || data.envelope_status === 'expired') {
clearInterval(_rpTimer);
claimBtn.disabled = true;
claimBtn.textContent = '礼包已过期';
statusMsg.style.color = '#9ca3af';
statusMsg.textContent = '红包已过期。';
return;
}
// 若已抢完
if (data.remaining_count <= 0 || data.envelope_status === 'completed') {
clearInterval(_rpTimer);
claimBtn.disabled = true;
claimBtn.textContent = '已全部抢完';
statusMsg.style.color = '#9ca3af';
statusMsg.textContent = '😊 礼包已全部被抢完啦!';
return;
}
// 若本人已领取
if (data.has_claimed) {
claimBtn.disabled = true;
claimBtn.textContent = '您已领取';
statusMsg.style.color = '#10b981';
statusMsg.textContent = '✅ 您已成功领取本次礼包!';
}
})
.catch(() => {}); // 静默忽略网络错误,不影响弹窗展示
};
// ── 关闭红包弹窗 ─────────────────────────────────
window.closeRedPacketModal = function() {
document.getElementById('red-packet-modal').style.display = 'none';
clearInterval(_rpTimer);
};
// 点击遮罩关闭
document.getElementById('red-packet-modal').addEventListener('click', function(e) {
if (e.target === this) closeRedPacketModal();
});
// ── 抢红包 ──────────────────────────────────────
/**
* 用户点击「立即抢红包」,调用后端 claim 接口。
*/
window.claimRedPacket = async function() {
if (!_rpEnvelopeId) return;
const btn = document.getElementById('rp-claim-btn');
btn.disabled = true;
btn.textContent = '抢包中…';
try {
const res = await fetch(`/red-packet/${_rpEnvelopeId}/claim`, {
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
}),
});
const data = await res.json();
const statusEl = document.getElementById('rp-status-msg');
if (res.ok && data.status === 'success') {
_rpClaimed = true;
btn.textContent = '🎉 已抢到!';
statusEl.style.color = '#16a34a';
statusEl.textContent = `恭喜!您抢到了 ${data.amount} 金币!`;
// 弹出全局 Toast
window.chatToast.show({
title: '🧧 礼包到账',
message: `恭喜您抢到了礼包 <b>${data.amount}</b> 金币!`,
icon: '🧧',
color: '#dc2626',
duration: 8000,
});
// 3 秒后自动关闭弹窗
setTimeout(() => closeRedPacketModal(), 3000);
} else {
statusEl.style.color = '#dc2626';
statusEl.textContent = data.message || '抢包失败';
// 若是「已领过」或「已抢完」则禁用按钮,否则解除禁用以重试
if (data.message && (data.message.includes('已经领过') || data.message.includes('已被抢完') ||
data.message.includes('已抢完'))) {
btn.textContent = '已参与';
} else {
btn.disabled = false;
btn.textContent = '🧧 立即抢红包';
}
}
} catch (e) {
document.getElementById('rp-status-msg').textContent = '网络异常,请重试';
document.getElementById('rp-status-msg').style.color = '#dc2626';
btn.disabled = false;
btn.textContent = '🧧 立即抢红包';
}
};
// ── 更新领取名单(被 WS 触发调用)───────────────
/**
* 收到「系统传音」中的领取播报时,同步更新弹窗内的名单与剩余数。
*
* @param {string} username 领取者用户名
* @param {number} amount 领取金额
* @param {number} remaining 剩余份数
*/
window.updateRedPacketClaimsUI = function(username, amount, remaining) {
const remainingEl = document.getElementById('rp-remaining');
if (remainingEl) remainingEl.textContent = remaining;
const listEl = document.getElementById('rp-claims-list');
const itemsEl = document.getElementById('rp-claims-items');
if (!listEl || !itemsEl) return;
listEl.style.display = 'block';
const item = document.createElement('div');
item.className = 'rp-claim-item';
item.innerHTML = `<span>${username}</span><span>+${amount} 金币</span>`;
itemsEl.prepend(item);
// 若已全部领完,更新按钮状态
if (remaining <= 0) {
const btn = document.getElementById('rp-claim-btn');
if (btn && !_rpClaimed) {
btn.disabled = true;
btn.textContent = '礼包已被抢完!';
}
clearInterval(_rpTimer);
// 3 秒后自动关闭
setTimeout(() => closeRedPacketModal(), 3000);
}
};
// ── WebSocket 监听 red-packet.sent ───────────────
/**
* 等待 Echo 就绪后注册 red-packet.sent 事件监听,
* 每次收到新红包时弹出红包卡片弹窗。
*/
function setupRedPacketListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRedPacketListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen('.red-packet.sent', (e) => {
// 收到红包事件弹出卡片type 决定金币/经验配色)
showRedPacketModal(
e.envelope_id,
e.sender_username,
e.total_amount,
e.total_count,
e.expire_seconds,
e.type || 'gold',
);
});
console.log('RedPacketSent 监听器已注册');
}
document.addEventListener('DOMContentLoaded', setupRedPacketListener);
})(); // end IIFE
</script>