新增功能:
- 礼包系统:superlevel 站长可发 888 数量 10 份礼包,支持金币/经验双类型
- 发包前三按钮选择(金币礼包 / 经验礼包 / 取消),使用 chatBanner 弹窗
- 聊天室系统公告含「立即抢包」按钮,金币红色/经验紫色配色区分
- WebSocket 实时推送红包弹窗卡片至所有在线用户
- Redis LPOP 原子分发 + 数据库 unique 约束防重领,并发安全
- 弹窗打开自动拉取服务端最新状态(剩余数量/已领/过期实时刷新)
- 新增 GET /red-packet/{id}/status 状态查询接口
- 新增 CurrencySource::RED_PACKET_RECV / RED_PACKET_RECV_EXP 枚举
安全加固:
- 后台用户编辑/强杀按钮仅 id=1 超管可见(前端隐藏 + 后端 403 双重拦截)
2817 lines
116 KiB
PHP
2817 lines
116 KiB
PHP
{{--
|
||
文件功能:聊天室前端交互脚本(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, // 自动关闭 ms,0=不关闭
|
||
* 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.chatToast(toast-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>
|