Files
chatroom/resources/views/chat/partials/scripts.blade.php
lkddi 1caaec5601 修复:关闭浏览器时 leave 不触发导致勤务日志不结算
- 新增 sendLeaveBeacon(),使用 navigator.sendBeacon 发送 leave 请求
- beforeunload 事件:关闭标签/浏览器/刷新均自动结算
- visibilitychange 事件:切到后台 30 秒后自动结算,切回来取消
- sendBeacon 比 fetch 更可靠,浏览器关闭时也能确保请求发出
2026-03-01 00:12:47 +08:00

1608 lines
69 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能聊天室前端交互脚本Blade 模板形式)
包含消息渲染、用户列表管理、发送消息、WebSocket 事件监听、
管理操作、存点心跳、头像选择等全部前端逻辑
通过 @include('chat.partials.scripts') 引入到 frame.blade.php
@author ChatRoom Laravel
@version 1.0.0
--}}
<script>
/**
* 聊天室前端交互逻辑
* 保留所有 WebSocket 事件监听,复刻原版 UI 交互
*/
// ── DOM 元素引用 ──────────────────────────────────────
const container = document.getElementById('chat-messages-container');
const container2 = document.getElementById('chat-messages-container2');
const userList = document.getElementById('online-users-list');
const toUserSelect = document.getElementById('to_user');
const onlineCount = document.getElementById('online-count');
const onlineCountBottom = document.getElementById('online-count-bottom');
let onlineUsers = {};
let autoScroll = true;
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
// ── Tab 切换 ──────────────────────────────────────
function switchTab(tab) {
// 切换名单/房间/贴图/酷库 面板
['users', 'rooms', 'emoji', 'action'].forEach(t => {
document.getElementById('panel-' + t).style.display = t === tab ? 'block' : 'none';
document.getElementById('tab-' + t).classList.toggle('active', t === tab);
});
// 贴图 Tab 懒加载
if (tab === 'emoji') {
document.querySelectorAll('#panel-emoji img[data-src]').forEach(img => {
img.src = img.dataset.src;
img.removeAttribute('data-src');
});
}
}
// ── 分屏切换 ──────────────────────────────────────
function changeSplitScreen(mode) {
const panes = document.getElementById('message-panes');
if (mode === '1') {
panes.classList.add('split-h');
} else {
panes.classList.remove('split-h');
}
}
// ── 表情插入 ──────────────────────────────────────
function insertEmoji(code) {
const input = document.getElementById('content');
input.value += code;
input.focus();
switchTab('users');
}
// ── 动作选择 ──────────────────────────────────────
function setAction(act) {
document.getElementById('action').value = act;
switchTab('users');
document.getElementById('content').focus();
}
// ── 自动滚屏 ──────────────────────────────────────
const autoScrollEl = document.getElementById('auto_scroll');
if (autoScrollEl) {
autoScrollEl.addEventListener('change', function() {
autoScroll = this.checked;
});
}
// ── 滚动到底部 ───────────────────────────────────
function scrollToBottom() {
if (autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
// ── 渲染在线人员列表(支持排序) ──────────────────
function renderUserList() {
userList.innerHTML = '';
toUserSelect.innerHTML = '<option value="大家">大家</option>';
// 在列表顶部添加"大家"条目(原版风格)
let allDiv = document.createElement('div');
allDiv.className = 'user-item';
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
allDiv.onclick = () => {
toUserSelect.value = '大家';
};
userList.appendChild(allDiv);
// ── AI 小助手(仅当全局开关开启时显示,与普通用户风格一致)──
if (window.chatContext.chatBotEnabled) {
let botDiv = document.createElement('div');
botDiv.className = 'user-item';
botDiv.innerHTML = `
<img class="user-head" src="/images/ai_bot.png" onerror="this.src='/images/headface/1.gif'">
<span class="user-name">AI小班长</span><span style="font-size:12px; margin-left:2px;" title="聊天机器人">🤖</span>
`;
botDiv.onclick = () => {
toUserSelect.value = 'AI小班长';
document.getElementById('content').focus();
};
userList.appendChild(botDiv);
// 在发言对象下拉框中也添加 AI 小助手
let botOption = document.createElement('option');
botOption.value = 'AI小班长';
botOption.textContent = '🤖 AI小班长';
toUserSelect.appendChild(botOption);
}
// 获取排序方式
const sortSelect = document.getElementById('user-sort-select');
const sortBy = sortSelect ? sortSelect.value : 'default';
// 构建用户数组并排序
let userArr = [];
for (let username in onlineUsers) {
userArr.push({
username,
...onlineUsers[username]
});
}
if (sortBy === 'name') {
userArr.sort((a, b) => a.username.localeCompare(b.username, 'zh'));
} else if (sortBy === 'level') {
userArr.sort((a, b) => (b.user_level || 0) - (a.user_level || 0));
}
let count = userArr.length;
userArr.forEach(user => {
const username = user.username;
let item = document.createElement('div');
item.className = 'user-item';
item.dataset.username = username;
const headface = (user.headface || '1.gif').toLowerCase();
// 徽章优先级:职务图标 > 管理员 > VIP
let badges = '';
if (user.position_icon) {
// 有职务显示职务图标hover 显示职务名称
const posTitle = (user.position_name || '在职') + ' · ' + username;
badges +=
`<span style="font-size:13px; margin-left:2px;" title="${posTitle}">${user.position_icon}</span>`;
} else if (user.is_admin) {
badges += `<span style="font-size:12px; margin-left:2px;" title="最高统帅">🎖️</span>`;
} else if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
badges +=
`<span style="font-size:12px; margin-left:2px; color:${vipColor};" title="${user.vip_name || 'VIP'}">${user.vip_icon}</span>`;
}
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
item.innerHTML = `
<img class="user-head" src="/images/headface/${headface}" onerror="this.src='/images/headface/1.gif'">
<span class="user-name" style="${nameColor}">${username}</span>${badges}
`;
item.onclick = () => {
toUserSelect.value = username;
document.getElementById('content').focus();
};
// 双击打开用户名片弹窗(全局统一入口)
item.ondblclick = () => openUserCard(username);
userList.appendChild(item);
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
option.textContent = username;
toUserSelect.appendChild(option);
}
});
onlineCount.innerText = count;
onlineCountBottom.innerText = count;
const footer = document.getElementById('online-count-footer');
if (footer) footer.innerText = count;
// 如果有搜索关键词,重新过滤
filterUserList();
}
/**
* 搜索/过滤用户列表
*/
function filterUserList() {
const searchInput = document.getElementById('user-search-input');
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : '';
const items = userList.querySelectorAll('.user-item');
items.forEach(item => {
if (!keyword) {
item.style.display = '';
return;
}
const name = (item.dataset.username || item.textContent || '').toLowerCase();
item.style.display = name.includes(keyword) ? '' : 'none';
});
}
/**
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)
*/
function appendMessage(msg) {
// 记录拉取到的最大消息ID用于本地清屏功能
if (msg && msg.id > _maxMsgId) {
_maxMsgId = msg.id;
}
const isMe = msg.from_user === window.chatContext.username;
const fontColor = msg.font_color || '#000000';
const div = document.createElement('div');
div.className = 'msg-line';
const timeStr = msg.sent_at || '';
let timeStrOverride = false;
// 系统用户名列表(不可被选为聊天对象)
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', 'AI小班长', '送花播报', '系统'];
// 动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说"
const actionTextMap = {
'微笑': {
type: 'emotion',
word: '微笑着'
},
'大笑': {
type: 'emotion',
word: '大笑着'
},
'愤怒': {
type: 'emotion',
word: '愤怒地'
},
'哭泣': {
type: 'emotion',
word: '哭泣着'
},
'害羞': {
type: 'emotion',
word: '害羞地'
},
'鄙视': {
type: 'emotion',
word: '鄙视地'
},
'得意': {
type: 'emotion',
word: '得意地'
},
'疑惑': {
type: 'emotion',
word: '疑惑地'
},
'同情': {
type: 'emotion',
word: '同情地'
},
'无奈': {
type: 'emotion',
word: '无奈地'
},
'拳打': {
type: 'verb',
word: '拳打了'
},
'飞吻': {
type: 'verb',
word: '飞吻了'
},
'偷看': {
type: 'verb',
word: '偷看了'
},
};
// 生成自然语序的动作串:情绪型=[人][着/地]对[目标][verb]:;动作型=[人][了][目标][verb]
const buildActionStr = (action, fromHtml, toHtml, verb = '说') => {
const info = actionTextMap[action];
if (!info) return `${fromHtml}对${toHtml}${action}${verb}`;
if (info.type === 'emotion') return `${fromHtml}${info.word}对${toHtml}${verb}`;
return `${fromHtml}${info.word}${toHtml}${verb}`;
};
// 用户名(单击切换发言对象,双击查看资料;系统用户仅显示文本)
const clickableUser = (uName, color) => {
if (systemUsers.includes(uName)) {
return `<span class="msg-user" style="color: ${color};">${uName}</span>`;
}
return `<span class="msg-user" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="openUserCard('${uName}')">${uName}</span>`;
};
// 系统播报用户(不包含 AI小班长使用军号图标AI小班长用专属图普通用户用头像
const buggleUsers = ['钓鱼播报', '星海小博士', '送花播报', '系统传音', '系统公告'];
const senderInfo = onlineUsers[msg.from_user];
const senderHead = ((senderInfo && senderInfo.headface) || '1.gif').toLowerCase();
let headImgSrc = `/images/headface/${senderHead}`;
if (msg.from_user === 'AI小班长') {
headImgSrc = '/images/ai_bot.png';
} else if (buggleUsers.includes(msg.from_user)) {
headImgSrc = '/images/bugle.png';
}
const headImg =
`<img src="${headImgSrc}" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
let html = '';
// 第一个判断分支:如果是纯 HTML 系统的进出播报
if (msg.action === 'system_welcome') {
div.style.cssText = 'margin: 3px 0;';
const iconImg =
`<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
html = `${iconImg} ${parsedContent}`;
}
// 接下来再判断各类发话人
else if (systemUsers.includes(msg.from_user)) {
if (msg.from_user === '系统公告') {
// 管理员公告:大字醒目红框样式
div.style.cssText =
'background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 6px; padding: 8px 12px; margin: 4px 0; box-shadow: 0 2px 4px rgba(239,68,68,0.15);';
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#dc2626') + '】';
});
html =
`<div style="font-weight: bold; color: #dc2626;">${parsedContent} <span style="color: #999; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (msg.from_user === '系统传音') {
// 自动升级播报 / 赠礼通知:金色左边框,轻量提示样式,不喧宾夺主
div.style.cssText =
'background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;';
html =
`<span style="color: #b45309;">🌟 ${msg.content}</span>`;
} else if (msg.from_user === '系统' && msg.to_user && msg.to_user !== '大家') {
// 系统私人通知(自动存点等):无头像,绿色左边框简洁条形样式
div.style.cssText =
'background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;';
html =
`<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
// 其他系统用户钓鱼播报、送花播报、AI小班长等普通样式
let giftHtml = '';
if (msg.gift_image) {
giftHtml =
`<img src="${msg.gift_image}" alt="${msg.gift_name || ''}" style="display:inline-block;width:40px;height:40px;vertical-align:middle;margin-left:6px;animation:giftBounce 0.6s ease-in-out;">`;
}
// 让带有【用户名】的系统通知变成可点击和双击的蓝色用户标
let parsedContent = msg.content;
// 利用正则匹配【用户名】结构,捕获组 $1 即是里面真正的用户名
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
html =
`${headImg}<span class="msg-user" style="color: ${fontColor}; font-weight: bold;">${msg.from_user}</span><span class="msg-content" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
}
} else if (msg.is_secret) {
if (msg.from_user === '系统') {
// 系统悄悄通知(自动存点等):绿色左边框条形,无头像,不显示"悄悄说"
div.style.cssText =
'background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;';
html =
`<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
// 普通悄悄话样式(原版:紫色斜体,使用自然语序动作)
const fromHtml = clickableUser(msg.from_user, '#cc00cc');
const toHtml = clickableUser(msg.to_user, '#cc00cc');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml, '悄悄说') :
`${fromHtml}对${toHtml}悄悄说:`;
html = `<span class="msg-secret">${headImg}${verbStr}${msg.content}</span>`;
}
} else if (msg.to_user && msg.to_user !== '大家') {
// 对特定对象说话
const fromHtml = clickableUser(msg.from_user, '#000099');
const toHtml = clickableUser(msg.to_user, '#000099');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml) :
`${fromHtml}对${toHtml}说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
} else {
// 对大家说话
const fromHtml = clickableUser(msg.from_user, '#000099');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, '大家') :
`${fromHtml}对大家说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
}
if (!timeStrOverride) {
html += ` <span class="msg-time">(${timeStr})</span>`;
}
div.innerHTML = html;
// 后端下发的带有 welcome_user 的也是系统欢迎/离开消息,加上属性标记
if (msg.welcome_user) {
div.setAttribute('data-system-user', msg.welcome_user);
// 收到后端来的新欢迎消息时,把界面上该用户旧的都删掉
const oldWelcomes = container.querySelectorAll(`[data-system-user="${msg.welcome_user}"]`);
oldWelcomes.forEach(el => el.remove());
}
// 路由规则(复刻原版):
// 公众窗口(say1):别人的公聊消息
// 包厢窗口(say2):自己发的消息 + 悄悄话 + 对自己说的消息
const isRelatedToMe = isMe ||
msg.is_secret ||
msg.to_user === window.chatContext.username;
// 自动存点通知:标记 data-autosave 属性,每次渲染时先删除旧的,实现"滚动替换"效果
const isAutoSave = (msg.from_user === '系统' || msg.from_user === '') &&
msg.content && msg.content.includes('自动存点');
if (isAutoSave) {
div.dataset.autosave = '1';
}
if (isRelatedToMe) {
// 删除旧的自动存点通知,保持包厢窗口整洁
if (isAutoSave) {
container2.querySelectorAll('[data-autosave="1"]').forEach(el => el.remove());
}
container2.appendChild(div);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
container.appendChild(div);
scrollToBottom();
}
}
// ── WebSocket 初始化 ─────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
}
});
// ── WebSocket 事件监听 ────────────────────────────
window.addEventListener('chat:here', (e) => {
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
});
renderUserList();
// 管理员自己进房时,在本地播放烟花(服务端广播可能在 WS 连上前已发出)
const ctx = window.chatContext;
if (ctx && ctx.userLevel >= ctx.superLevel && typeof EffectManager !== 'undefined') {
// 延迟 800ms 确保页面渲染完成再播特效
setTimeout(() => EffectManager.play('fireworks'), 800);
}
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
renderUserList();
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
renderUserList();
});
window.addEventListener('chat:message', (e) => {
const msg = e.detail;
if (msg.is_secret && msg.from_user !== window.chatContext.username && msg.to_user !== window
.chatContext.username) {
return;
}
appendMessage(msg);
});
window.addEventListener('chat:kicked', (e) => {
if (e.detail.username === window.chatContext.username) {
alert("您已被管理员踢出房间!" + (e.detail.reason ? "\n原因:" + e.detail.reason : ""));
window.location.href = "{{ route('rooms.index') }}";
}
});
// ── 禁言状态(本地计时器) ──
let isMutedUntil = 0;
window.addEventListener('chat:muted', (e) => {
const d = e.detail;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const isMe = d.username === window.chatContext.username;
// 禁言通知:自己被禁言显示在包厢(say2),其他人显示在公聊(say1)
const div = document.createElement('div');
div.className = 'msg-line';
div.innerHTML =
`<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
const targetContainer = isMe ? document.getElementById('say2') : container;
if (targetContainer) {
targetContainer.appendChild(div);
targetContainer.scrollTop = targetContainer.scrollHeight;
}
// 如果是自己被禁言,设置本地禁言计时
if (isMe && d.mute_time > 0) {
isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
const contentInput = document.getElementById('content');
const operatorName = d.operator || '管理员';
if (contentInput) {
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
contentInput.disabled = true;
// 到期自动恢复
setTimeout(() => {
isMutedUntil = 0;
contentInput.placeholder = '在这里输入聊天内容,按 Enter 发送...';
contentInput.disabled = false;
const unmuteDiv = document.createElement('div');
unmuteDiv.className = 'msg-line';
unmuteDiv.innerHTML =
'<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
// 解禁提示也显示在包厢窗口
const say2 = document.getElementById('say2');
if (say2) {
say2.appendChild(unmuteDiv);
say2.scrollTop = say2.scrollHeight;
}
}, d.mute_time * 60 * 1000);
}
}
});
window.addEventListener('chat:title-updated', (e) => {
document.getElementById('room-title-display').innerText = e.detail.title;
});
// ── 管理员全员清屏事件(等待 Echo 就绪后监听) ───────
function setupScreenClearedListener() {
if (!window.Echo || !window.chatContext) {
// Echo 或 chatContext 还没就绪,延迟重试
setTimeout(setupScreenClearedListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen('ScreenCleared', (e) => {
console.log('收到全员清屏事件:', e);
const operator = e.operator;
// 清除公聊窗口所有消息
const say1 = document.getElementById('chat-messages-container');
if (say1) say1.innerHTML = '';
// 清除包厢窗口中非悄悄话的消息
const say2 = document.getElementById('chat-messages-container2');
if (say2) {
const items = say2.querySelectorAll('.msg-line');
items.forEach(item => {
// 保留悄悄话消息(含 msg-secret 类)
if (!item.querySelector('.msg-secret')) {
item.remove();
}
});
}
// 显示清屏提示
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
sysDiv.innerHTML =
`<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${operator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
console.log('ScreenCleared 监听器已注册');
}
// DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载
document.addEventListener('DOMContentLoaded', setupScreenClearedListener);
// ── 开发日志发布通知(仅 Room 1 大厅可见)────────────
/**
* 监听 ChangelogPublished 事件,在大厅聊天区展示系统通知
* 通知包含版本号、标题和可点击的查看链接
* 复用「系统传音」样式(金色左边框,不喧宾夺主)
*/
function setupChangelogPublishedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupChangelogPublishedListener, 500);
return;
}
// 仅在 Room 1星光大厅时监听
if (window.chatContext.roomId !== 1) {
return;
}
window.Echo.join('room.1')
.listen('.ChangelogPublished', (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
// 金色左边框通知样式(复用「系统传音」风格)
sysDiv.style.cssText =
'background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;';
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
📋 【版本更新】v${e.version} · ${e.title}
<a href="${e.url}" target="_blank" rel="noopener"
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
查看详情 →
</a>
</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById('chat-messages-container');
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
console.log('ChangelogPublished 监听器已注册Room 1 专属)');
}
document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener);
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
window.addEventListener('chat:effect', (e) => {
const type = e.detail?.type;
const target = e.detail?.target_username; // null = 全员otherwise 指定昵称
const myName = window.chatContext?.username;
// null 表示全员,或者 target 匹配自己才播放
if (type && typeof EffectManager !== 'undefined') {
if (!target || target === myName) {
EffectManager.play(type);
}
}
});
/**
* 管理员点击特效按钮,向后端 POST /command/effect
*
* @param {string} type 特效类型fireworks / rain / lightning
*/
function triggerEffect(type) {
const roomId = window.chatContext?.roomId;
if (!roomId) return;
fetch('/command/effect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
body: JSON.stringify({
room_id: roomId,
type
}),
}).then(r => r.json()).then(data => {
if (data.status !== 'success') alert(data.message);
}).catch(err => console.error('特效触发失败:', err));
}
window.triggerEffect = triggerEffect;
// ── 字号设置(持久化到 localStorage─────────────────
/**
* 应用字号到聊天消息窗口,并保存到 localStorage
*
* @param {string|number} size 字号大小px 数字)
*/
function applyFontSize(size) {
const px = parseInt(size, 10);
if (isNaN(px) || px < 10 || px > 30) return;
// 同时应用到公聊窗和包厢窗
const c1 = document.getElementById('chat-messages-container');
const c2 = document.getElementById('chat-messages-container2');
if (c1) c1.style.fontSize = px + 'px';
if (c2) c2.style.fontSize = px + 'px';
// 持久化key 带房间 ID不同房间各自记住
const key = 'chat_font_size';
localStorage.setItem(key, px);
// 同步 select 显示
const sel = document.getElementById('font_size_select');
if (sel) sel.value = String(px);
}
window.applyFontSize = applyFontSize;
// 页面加载后从 localStorage 恢复之前保存的字号
document.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem('chat_font_size');
if (saved) {
applyFontSize(saved);
}
});
// ── 发送消息Enter 发送,防 IME 输入法重复触发)────────
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
let _imeComposing = false;
const _contentInput = document.getElementById('content');
// 中文/日文等 IME 组词开始
_contentInput.addEventListener('compositionstart', () => {
_imeComposing = true;
});
// 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉
_contentInput.addEventListener('compositionend', () => {
setTimeout(() => {
_imeComposing = false;
}, 10);
});
_contentInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// IME 正在组词时(如选候选汉字),不触发发送
if (_imeComposing) return;
sendMessage(e);
}
});
/**
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)
*/
let _isSending = false; // 发送中防重入标记
async function sendMessage(e) {
if (e) e.preventDefault();
if (_isSending) return; // 上一次还没结束,忽略
_isSending = true;
// 前端禁言检查
if (isMutedUntil > Date.now()) {
const remaining = Math.ceil((isMutedUntil - Date.now()) / 1000);
const remainMin = Math.ceil(remaining / 60);
// 在聊天窗口显示持久提示,避免弹窗消失太快
const muteDiv = document.createElement('div');
muteDiv.className = 'msg-line';
muteDiv.innerHTML =
`<span style="color: #dc2626; font-weight: bold;">【提示】您正在禁言中,还需等待约 ${remainMin} 分钟(${remaining} 秒)后方可发言。</span>`;
const container2 = document.getElementById('say2');
if (container2) {
container2.appendChild(muteDiv);
container2.scrollTop = container2.scrollHeight;
}
_isSending = false;
return;
}
const form = document.getElementById('chat-form');
const formData = new FormData(form);
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const content = formData.get('content').trim();
if (!content) {
contentInput.focus();
_isSending = false;
return;
}
// 如果发言对象是 AI 小助手,走专用机器人 API
const toUser = formData.get('to_user');
if (toUser === 'AI小班长') {
contentInput.value = '';
contentInput.focus();
_isSending = false;
sendToChatBot(content); // 异步调用,不阻塞全局发送
return;
}
submitBtn.disabled = true;
try {
const response = await fetch(window.chatContext.sendUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
},
body: formData
});
const data = await response.json();
if (response.ok && data.status === 'success') {
contentInput.value = '';
contentInput.focus();
} else {
alert('发送失败: ' + (data.message || JSON.stringify(data.errors)));
}
} catch (error) {
alert('网络连接错误,消息发送失败!');
console.error(error);
} finally {
submitBtn.disabled = false;
_isSending = false; // 释放发送锁,允许下次发送
}
}
// ── 设置房间公告 ─────────────────────────────────────
async function promptAnnouncement() {
const currentText = document.getElementById('announcement-text')?.textContent?.trim() || '';
const newText = prompt('请输入新的房间公告/祝福语:', currentText);
if (newText === null || newText.trim() === '') return;
try {
const res = await fetch(`/room/${window.chatContext.roomId}/announcement`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
announcement: newText.trim()
})
});
const data = await res.json();
if (data.status === 'success') {
const marquee = document.getElementById('announcement-text');
if (marquee) marquee.textContent = newText.trim();
alert('公告已更新!');
} else {
alert(data.message || '更新失败');
}
} catch (e) {
alert('设置公告失败:' + e.message);
}
}
// ── 站长公屏讲话 ─────────────────────────────────────
async function promptAnnounceMessage() {
const content = prompt('请输入公屏讲话内容:');
if (!content || !content.trim()) return;
try {
const res = await fetch('/command/announce', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
content: content.trim(),
room_id: window.chatContext.roomId,
})
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
alert(data.message || '发送失败');
}
} catch (e) {
alert('发送失败:' + e.message);
}
}
// ── 管理员全员清屏 ─────────────────────────────────────
async function adminClearScreen() {
if (!confirm('确定要清除所有人的聊天记录吗?(悄悄话将保留)')) return;
try {
const res = await fetch('/command/clear-screen', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
})
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
alert(data.message || '清屏失败');
}
} catch (e) {
alert('清屏失败:' + e.message);
}
}
// ── 本地清屏(仅限自己的屏幕)───────────────────────────
function localClearScreen() {
// 清理公聊窗口
const say1 = document.getElementById('chat-messages-container');
if (say1) say1.innerHTML = '';
// 清理包厢窗口
const say2 = document.getElementById('chat-messages-container2');
if (say2) say2.innerHTML = '';
// 将当前最大消息 ID 保存至本地,刷新时只显示大于此 ID 的历史记录
localStorage.setItem(`local_clear_msg_id_${window.chatContext.roomId}`, _maxMsgId);
// 插入清屏提示
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2,
'0') + ':' + now.getSeconds().toString().padStart(2, '0');
sysDiv.innerHTML =
`<span style="color: #64748b; font-weight: bold;">🧹 您已执行本地清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
}
// ── 滚屏开关 ─────────────────────────────────────
function toggleAutoScroll() {
autoScroll = !autoScroll;
const cb = document.getElementById('auto_scroll');
if (cb) cb.checked = autoScroll;
const statusEl = document.getElementById('scroll-status');
if (statusEl) statusEl.textContent = autoScroll ? '开' : '关';
}
// ── 退出房间 ─────────────────────────────────────
async function leaveRoom() {
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 更可靠)
function sendLeaveBeacon() {
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);
}
// beforeunload关闭标签/浏览器/刷新 时触发
window.addEventListener('beforeunload', sendLeaveBeacon);
// visibilitychange切换到后台标签超过30秒也结算防止长期挂机不算时长
let visibilityTimer = null;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 切到后台30秒后结算
visibilityTimer = setTimeout(sendLeaveBeacon, 30 * 1000);
} else {
// 切回来,取消结算
clearTimeout(visibilityTimer);
visibilityTimer = null;
}
});
// ── 掉线检测计数器 ──
let heartbeatFailCount = 0;
const MAX_HEARTBEAT_FAILS = 3;
// ── 存点功能(手动 + 自动)─────────────────────
async function saveExp(silent = false) {
if (!window.chatContext || !window.chatContext.heartbeatUrl) return;
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')
.getAttribute('content'),
'Accept': 'application/json'
}
});
// 检测登录态失效
if (response.status === 401 || response.status === 419) {
alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。');
window.location.href = '/';
return;
}
const data = await response.json();
if (response.ok && data.status === 'success') {
heartbeatFailCount = 0;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const d = data.data;
const levelTitle = d.title || '普通会员';
let levelInfo = '';
if (d.is_max_level) {
levelInfo = `级别(${d.user_level});经验(${d.exp_num});金币(${d.jjb}枚);已满级。`;
} else {
levelInfo = `级别(${d.user_level});经验(${d.exp_num});金币(${d.jjb}枚)。`;
}
// 本次获得的奖励提示
let gainInfo = '';
if (d.exp_gain > 0 || d.jjb_gain > 0) {
const parts = [];
if (d.exp_gain > 0) parts.push(`经验+${d.exp_gain}`);
if (d.jjb_gain > 0) parts.push(`金币+${d.jjb_gain}`);
gainInfo = `(本次: ${parts.join(', ')})`;
}
if (data.data.leveled_up) {
const upDiv = document.createElement('div');
upDiv.className = 'msg-line';
upDiv.innerHTML =
`<span style="color: #d97706; font-weight: bold;">【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(upDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}
if (!silent) {
const detailDiv = document.createElement('div');
detailDiv.className = 'msg-line';
detailDiv.innerHTML =
`<span style="color: green;">【${levelTitle}存点】您的最新情况:${levelInfo} ${gainInfo}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(detailDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
return;
}
}
} catch (e) {
console.error('存点失败', e);
heartbeatFailCount++;
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
alert('⚠️ 与服务器的连接已断开,请检查网络后重新登录。');
window.location.href = '/';
return;
}
if (!silent) {
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML = `<span style="color: red;">【系统】存点失败,请稍后重试</span>`;
container2.appendChild(sysDiv);
}
}
}
// ── 自动存点心跳每60秒自动存一次───────────
const HEARTBEAT_INTERVAL = 60 * 1000;
setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
setTimeout(() => saveExp(true), 10000);
// ── 头像选择器(原版 fw.asp 功能)───────────────
let avatarPickerLoaded = false;
/**
* 打开头像选择弹窗
*/
function openAvatarPicker() {
const modal = document.getElementById('avatar-picker-modal');
modal.style.display = 'flex';
if (!avatarPickerLoaded) {
loadHeadfaces();
avatarPickerLoaded = true;
}
}
/**
* 关闭头像选择弹窗
*/
function closeAvatarPicker() {
document.getElementById('avatar-picker-modal').style.display = 'none';
}
/**
* 加载头像列表
*/
async function loadHeadfaces() {
const grid = document.getElementById('avatar-grid');
grid.innerHTML = '<div style="text-align:center;padding:20px;color:#999;">加载中...</div>';
try {
const res = await fetch('/headface/list');
const data = await res.json();
grid.innerHTML = '';
data.headfaces.forEach(file => {
const img = document.createElement('img');
img.src = '/images/headface/' + file;
img.className = 'avatar-option';
img.title = file;
img.dataset.file = file;
img.onerror = () => img.style.display = 'none';
img.onclick = () => selectAvatar(file, img);
grid.appendChild(img);
});
} catch (e) {
grid.innerHTML = '<div style="text-align:center;padding:20px;color:red;">加载失败</div>';
}
}
/**
* 选中一个头像
*/
function selectAvatar(file, imgEl) {
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
imgEl.classList.add('selected');
document.getElementById('avatar-preview').src = '/images/headface/' + file;
document.getElementById('avatar-selected-name').textContent = file;
document.getElementById('avatar-save-btn').disabled = false;
document.getElementById('avatar-save-btn').dataset.file = file;
}
/**
* 保存选中的头像
*/
async function saveAvatar() {
const btn = document.getElementById('avatar-save-btn');
const file = btn.dataset.file;
if (!file) return;
btn.disabled = true;
btn.textContent = '保存中...';
try {
const res = await fetch('/headface/change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({
headface: file
})
});
const data = await res.json();
if (data.status === 'success') {
alert('头像修改成功!');
const myName = window.chatContext.username;
if (onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
renderUserList();
closeAvatarPicker();
} else {
alert(data.message || '修改失败');
}
} catch (e) {
alert('网络错误');
}
btn.disabled = false;
btn.textContent = '确定更换';
}
// ── 钓鱼小游戏(复刻原版 diaoyu/ 功能)─────────────
let fishingTimer = null;
let fishingReelTimeout = null;
/**
* 开始钓鱼 — 调用抛竿 API花费金币显示等待动画
*/
async function startFishing() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
try {
const res = await fetch(window.chatContext.fishCastUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
alert(data.message || '钓鱼失败');
btn.disabled = false;
return;
}
// 在包厢窗口显示抛竿消息
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const castDiv = document.createElement('div');
castDiv.className = 'msg-line';
castDiv.innerHTML =
`<span style="color: #2563eb; font-weight: bold;">🎣【钓鱼】</span>${data.message}<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(castDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
// 等待鱼上钩(后端返回的随机等待秒数)
btn.textContent = '🎣 等待中...';
fishingTimer = setTimeout(() => {
// 鱼上钩了!
const hookDiv = document.createElement('div');
hookDiv.className = 'msg-line';
hookDiv.innerHTML =
'<span style="color: #d97706; font-weight: bold; font-size: 14px;">🐟 鱼上钩了!快点击 <span onclick="reelFish()" style="text-decoration:underline; cursor:pointer; color:#dc2626;">[拉竿]</span> 按钮!</span>';
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
btn.textContent = '🎣 拉竿!';
btn.disabled = false;
btn.onclick = reelFish;
// 15 秒内不拉竿,鱼跑掉
fishingReelTimeout = setTimeout(() => {
const missDiv = document.createElement('div');
missDiv.className = 'msg-line';
missDiv.innerHTML =
'<span style="color: #999;">💨 你反应太慢了,鱼跑掉了...</span>';
container2.appendChild(missDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
resetFishingBtn();
}, 15000);
}, data.wait_time * 1000);
} catch (e) {
alert('网络错误:' + e.message);
btn.disabled = false;
}
}
/**
* 拉竿 — 调用收竿 API获取随机结果
*/
async function reelFish() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 拉竿中...';
// 取消跑鱼计时器
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
try {
const res = await fetch(window.chatContext.fishReelUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
if (res.ok && data.status === 'success') {
const r = data.result;
const color = r.exp >= 0 ? '#16a34a' : '#dc2626';
const resultDiv = document.createElement('div');
resultDiv.className = 'msg-line';
resultDiv.innerHTML =
`<span style="color: ${color}; font-weight: bold;">${r.emoji}【钓鱼结果】</span>${r.message}` +
` <span style="color: #666; font-size: 11px;">(当前经验:${data.exp_num} 金币:${data.jjb}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(resultDiv);
} else {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML =
`<span style="color: red;">【钓鱼】${data.message || '操作失败'}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(errDiv);
}
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
alert('网络错误:' + e.message);
}
resetFishingBtn();
}
/**
* 重置钓鱼按钮状态
*/
function resetFishingBtn() {
const btn = document.getElementById('fishing-btn');
btn.textContent = '🎣 钓鱼';
btn.disabled = false;
btn.onclick = startFishing;
fishingTimer = null;
fishingReelTimeout = null;
}
// ── AI 聊天机器人 ──────────────────────────────────
let chatBotSending = false;
/**
* 发送消息给 AI 机器人
* 先在包厢窗口显示用户消息,再调用 API 获取回复
*/
async function sendToChatBot(content) {
if (chatBotSending) {
alert('AI 正在思考中,请稍候...');
return;
}
chatBotSending = true;
// 显示"思考中"提示
// 延迟显示"思考中",让广播消息先到达
const thinkDiv = document.createElement('div');
thinkDiv.className = 'msg-line';
thinkDiv.innerHTML = '<span style="color: #16a34a;">🤖 <b>AI小班长</b> 正在思考中...</span>';
setTimeout(() => {
container2.appendChild(thinkDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}, 500);
try {
const res = await fetch(window.chatContext.chatBotUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
message: content,
room_id: window.chatContext.roomId
})
});
const data = await res.json();
// 移除"思考中"提示(消息已通过广播显示)
thinkDiv.remove();
if (!res.ok || data.status !== 'success') {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML = `<span style="color: #dc2626;">🤖【AI小班长】${data.message || '回复失败,请稍后重试'}</span>`;
container.appendChild(errDiv);
}
} catch (e) {
thinkDiv.remove();
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML = '<span style="color: #dc2626;">🤖【AI小班长】网络连接错误请稍后重试</span>';
container.appendChild(errDiv);
}
chatBotSending = false;
scrollToBottom();
}
/**
* 清除与 AI 小助手的对话上下文
*/
async function clearChatBotContext() {
try {
const res = await fetch(window.chatContext.chatBotClearUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
const data = await res.json();
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML = '<span style="color: #16a34a;">🤖【系统】' + (data.message || '对话已重置') + '</span>';
container2.appendChild(sysDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
alert('清除失败:' + e.message);
}
}
/**
* HTML 转义函数,防止 XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ══════════════════════════════════════════
// 任命公告:复用现有礼花特效 + 隆重弹窗
// ══════════════════════════════════════════
/**
* 显示任命公告弹窗居中5 秒后淡出)
*/
function showAppointmentBanner(data) {
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
const isRevoke = data.type === 'revoke';
const banner = document.createElement('div');
banner.id = 'appointment-banner';
banner.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 99999; text-align: center; pointer-events: none;
animation: appoint-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
`;
if (isRevoke) {
banner.innerHTML = `
<div style="background: linear-gradient(135deg, #374151, #4b5563, #6b7280);
border-radius: 20px; padding: 24px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.15); backdrop-filter: blur(8px);">
<div style="font-size: 36px; margin-bottom: 8px;">📋</div>
<div style="color: #d1d5db; font-size: 12px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
── 职务撤销 ──
</div>
<div style="color: white; font-size: 20px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}
</div>
<div style="color: #d1d5db; font-size: 13px; margin-top: 8px;">
<strong style="color: #f3f4f6;">${dept}${escapeHtml(data.position_name)}</strong> 职务已被撤销
</div>
<div style="color: rgba(255,255,255,0.4); font-size: 11px; margin-top: 10px;">
由 ${escapeHtml(data.operator_name)} 执行 · ${new Date().toLocaleTimeString('zh-CN')}
</div>
</div>
`;
} else {
banner.innerHTML = `
<div style="background: linear-gradient(135deg, #4f46e5, #7c3aed, #db2777);
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);">
<div style="font-size: 40px; margin-bottom: 8px;">🎊🎖️🎊</div>
<div style="color: #fde68a; font-size: 13px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
══ 任命公告 ══
</div>
<div style="color: white; font-size: 22px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}
</div>
<div style="color: #e0e7ff; font-size: 14px; margin-top: 8px;">
荣任 <strong style="color: #fde68a;">${dept}${escapeHtml(data.position_name)}</strong>
</div>
<div style="color: rgba(255,255,255,0.5); font-size: 11px; margin-top: 10px;">
由 ${escapeHtml(data.operator_name)} 任命 · ${new Date().toLocaleTimeString('zh-CN')}
</div>
</div>
`;
}
// 弹窗动画关键帧(动态注入一次)
if (!document.getElementById('appoint-keyframes')) {
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);
}
document.body.appendChild(banner);
// 4.5 秒后淡出移除
setTimeout(() => {
banner.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => banner.remove(), 500);
}, 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>