2026-02-26 21:10:34 +08:00
|
|
|
|
{{--
|
2026-03-09 11:30:11 +08:00
|
|
|
|
文件功能:聊天室核心前端交互脚本(Blade 模板形式)
|
|
|
|
|
|
|
|
|
|
|
|
包含:
|
|
|
|
|
|
1. 消息渲染与路由(appendMessage)
|
|
|
|
|
|
2. 在线用户列表管理(renderUserList / filterUserList)
|
|
|
|
|
|
3. WebSocket 事件监听(chat:here / chat:message / chat:muted 等)
|
|
|
|
|
|
4. 管理操作(adminClearScreen / promptAnnouncement 等)
|
|
|
|
|
|
5. 存点心跳(saveExp,60秒自动)
|
|
|
|
|
|
6. 钓鱼小游戏(startFishing / reelFish / autoFish)
|
|
|
|
|
|
7. 发送消息(sendMessage,IME 防重触发)
|
|
|
|
|
|
8. 特效控制(triggerEffect / applyFontSize / toggleSoundMute)
|
2026-04-14 22:25:16 +08:00
|
|
|
|
9. 系统播报屏蔽(toggleBlockMenu / toggleBlockedSystemSender)
|
2026-03-09 11:30:11 +08:00
|
|
|
|
|
|
|
|
|
|
已拆分至独立文件:
|
|
|
|
|
|
- window.chatBanner → chat-banner.blade.php
|
|
|
|
|
|
- 头像选择器 JS → layout/toolbar.blade.php
|
|
|
|
|
|
- 好友通知/chatBanner监听 → user-actions.blade.php
|
|
|
|
|
|
- 红包 HTML+CSS+JS → games/red-packet-panel.blade.php
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
通过 @include('chat.partials.scripts') 引入到 frame.blade.php
|
|
|
|
|
|
|
2026-03-09 11:30:11 +08:00
|
|
|
|
@author ChatRoom Laravel
|
|
|
|
|
|
@version 2.0.0
|
2026-02-26 21:10:34 +08:00
|
|
|
|
--}}
|
2026-03-09 11:30:11 +08:00
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
<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');
|
2026-04-14 22:25:16 +08:00
|
|
|
|
const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = 'chat_blocked_system_senders';
|
2026-04-14 22:48:29 +08:00
|
|
|
|
const CHAT_SOUND_MUTED_STORAGE_KEY = 'chat_sound_muted';
|
2026-04-17 15:27:40 +08:00
|
|
|
|
const BLOCKABLE_SYSTEM_SENDERS = ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子'];
|
2026-04-22 10:37:17 +08:00
|
|
|
|
const hoverTooltip = document.getElementById('chat-hover-tooltip');
|
|
|
|
|
|
let activeTooltipTrigger = null;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-18 20:58:33 +08:00
|
|
|
|
// ── 消息区:手机端双触发打开用户名片(PC 端靠 ondblclick 内联属性)──
|
|
|
|
|
|
// span[data-u] 由 clickableUser() 生成,touchend 委托至容器避免每条消息单独绑定
|
|
|
|
|
|
(function _bindMsgDoubleTap() {
|
|
|
|
|
|
let _lastTapTarget = null;
|
|
|
|
|
|
let _lastTapTime = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** touchend 委托处理函数 */
|
|
|
|
|
|
function _onMsgTouch(e) {
|
|
|
|
|
|
const span = e.target.closest('[data-u]');
|
|
|
|
|
|
if (!span) return;
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (span === _lastTapTarget && now - _lastTapTime < 300) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
openUserCard(span.dataset.u);
|
|
|
|
|
|
_lastTapTarget = null;
|
|
|
|
|
|
_lastTapTime = 0;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_lastTapTarget = span;
|
|
|
|
|
|
_lastTapTime = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 两个聊天容器(公屏 + 包厢)都绑定
|
|
|
|
|
|
[container, container2].forEach(c => {
|
|
|
|
|
|
if (c) { c.addEventListener('touchend', _onMsgTouch, { passive: false }); }
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|
2026-02-26 21:10:34 +08:00
|
|
|
|
let onlineUsers = {};
|
|
|
|
|
|
let autoScroll = true;
|
2026-02-28 11:22:18 +08:00
|
|
|
|
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
|
2026-04-14 22:48:29 +08:00
|
|
|
|
const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {});
|
|
|
|
|
|
let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 规整聊天室偏好对象,过滤非法配置并补齐默认值。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Record<string, any>} raw 原始偏好对象
|
|
|
|
|
|
* @returns {Object}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function normalizeChatPreferences(raw) {
|
|
|
|
|
|
const blocked = Array.isArray(raw?.blocked_system_senders)
|
|
|
|
|
|
? raw.blocked_system_senders.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
// 默认所有用户都处于“不屏蔽”的开放状态,只有显式勾选的项目才会进入该列表。
|
|
|
|
|
|
blocked_system_senders: Array.from(new Set(blocked)),
|
|
|
|
|
|
sound_muted: Boolean(raw?.sound_muted),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从 localStorage 读取已屏蔽的系统播报发送者列表。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {string[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function loadBlockedSystemSenders() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = JSON.parse(localStorage.getItem(BLOCKED_SYSTEM_SENDERS_STORAGE_KEY) || '[]');
|
|
|
|
|
|
if (!Array.isArray(saved)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 仅允许白名单内的系统播报项进入配置,避免脏数据污染。
|
|
|
|
|
|
return saved.filter(sender => BLOCKABLE_SYSTEM_SENDERS.includes(sender));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将当前屏蔽配置持久化到 localStorage。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function persistBlockedSystemSenders() {
|
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
|
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
|
|
|
|
|
|
JSON.stringify(Array.from(blockedSystemSenders))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:48:29 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 判断当前禁音开关是否处于打开状态。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function isSoundMuted() {
|
|
|
|
|
|
const muteCheckbox = document.getElementById('sound_muted');
|
|
|
|
|
|
|
|
|
|
|
|
if (muteCheckbox) {
|
|
|
|
|
|
return Boolean(muteCheckbox.checked);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取当前聊天室偏好快照。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {Object}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildChatPreferencesPayload() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
blocked_system_senders: Array.from(blockedSystemSenders),
|
|
|
|
|
|
sound_muted: isSoundMuted(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将聊天室偏好写入本地缓存,供刷新前快速恢复与迁移兜底。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function persistChatPreferencesToLocal() {
|
|
|
|
|
|
persistBlockedSystemSenders();
|
|
|
|
|
|
localStorage.setItem(CHAT_SOUND_MUTED_STORAGE_KEY, isSoundMuted() ? '1' : '0');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将当前聊天室偏好保存到当前登录账号。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function saveChatPreferences() {
|
|
|
|
|
|
const payload = buildChatPreferencesPayload();
|
|
|
|
|
|
|
|
|
|
|
|
persistChatPreferencesToLocal();
|
|
|
|
|
|
|
|
|
|
|
|
if (!window.chatContext?.chatPreferencesUrl) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(window.chatContext.chatPreferencesUrl, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('save chat preferences failed');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
if (data?.status === 'success') {
|
|
|
|
|
|
window.chatContext.chatPreferences = normalizeChatPreferences(data.data || payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('聊天室偏好保存失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:25:16 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 同步屏蔽菜单中的复选框状态。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function syncBlockedSystemSenderCheckboxes() {
|
|
|
|
|
|
const fishingCheckbox = document.getElementById('block-sender-fishing');
|
|
|
|
|
|
const doctorCheckbox = document.getElementById('block-sender-doctor');
|
2026-04-14 22:31:11 +08:00
|
|
|
|
const baccaratCheckbox = document.getElementById('block-sender-baccarat');
|
|
|
|
|
|
const horseRaceCheckbox = document.getElementById('block-sender-horse-race');
|
2026-04-17 15:27:40 +08:00
|
|
|
|
const mysteryBoxCheckbox = document.getElementById('block-sender-mystery-box');
|
2026-04-14 22:25:16 +08:00
|
|
|
|
|
|
|
|
|
|
if (fishingCheckbox) {
|
|
|
|
|
|
fishingCheckbox.checked = blockedSystemSenders.has('钓鱼播报');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (doctorCheckbox) {
|
|
|
|
|
|
doctorCheckbox.checked = blockedSystemSenders.has('星海小博士');
|
|
|
|
|
|
}
|
2026-04-14 22:31:11 +08:00
|
|
|
|
|
|
|
|
|
|
if (baccaratCheckbox) {
|
|
|
|
|
|
baccaratCheckbox.checked = blockedSystemSenders.has('百家乐');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (horseRaceCheckbox) {
|
|
|
|
|
|
horseRaceCheckbox.checked = blockedSystemSenders.has('跑马');
|
|
|
|
|
|
}
|
2026-04-17 15:27:40 +08:00
|
|
|
|
|
|
|
|
|
|
if (mysteryBoxCheckbox) {
|
|
|
|
|
|
mysteryBoxCheckbox.checked = blockedSystemSenders.has('神秘箱子');
|
|
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 22:31:11 +08:00
|
|
|
|
* 根据消息内容识别其对应的屏蔽规则键。
|
2026-04-14 22:25:16 +08:00
|
|
|
|
*
|
2026-04-14 22:31:11 +08:00
|
|
|
|
* @param {Record<string, any>} msg 消息对象
|
|
|
|
|
|
* @returns {string|null}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
*/
|
2026-04-14 22:31:11 +08:00
|
|
|
|
function resolveBlockedSystemSenderKey(msg) {
|
|
|
|
|
|
const fromUser = String(msg?.from_user || '');
|
|
|
|
|
|
const content = String(msg?.content || '');
|
|
|
|
|
|
|
|
|
|
|
|
if (fromUser === '钓鱼播报') {
|
|
|
|
|
|
return '钓鱼播报';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:27:40 +08:00
|
|
|
|
if (fromUser === '神秘箱子') {
|
|
|
|
|
|
return '神秘箱子';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:31:11 +08:00
|
|
|
|
if (fromUser === '星海小博士') {
|
|
|
|
|
|
return '星海小博士';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 12:14:10 +08:00
|
|
|
|
// 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以“系统传音”发送,但正文里带有“钓鱼播报”字样。
|
|
|
|
|
|
if ((fromUser === '系统传音' || fromUser === '系统') && (content.includes('钓鱼播报') || content.includes('自动钓鱼模式'))) {
|
|
|
|
|
|
return '钓鱼播报';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:27:40 +08:00
|
|
|
|
if ((fromUser === '系统传音' || fromUser === '系统') && content.includes('神秘箱子')) {
|
|
|
|
|
|
return '神秘箱子';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:31:11 +08:00
|
|
|
|
if ((fromUser === '系统传音' || fromUser === '系统') && content.includes('百家乐')) {
|
|
|
|
|
|
return '百家乐';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ((fromUser === '系统传音' || fromUser === '系统') && (content.includes('赛马') || content.includes('跑马'))) {
|
|
|
|
|
|
return '跑马';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
2026-04-14 22:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 22:31:11 +08:00
|
|
|
|
* 批量切换当前已渲染消息的显示状态。
|
2026-04-14 22:25:16 +08:00
|
|
|
|
*
|
2026-04-14 22:31:11 +08:00
|
|
|
|
* @param {string} blockKey 屏蔽规则键
|
|
|
|
|
|
* @param {boolean} hidden true = 隐藏,false = 恢复显示
|
2026-04-14 22:25:16 +08:00
|
|
|
|
*/
|
2026-04-14 22:31:11 +08:00
|
|
|
|
function setRenderedMessagesVisibilityBySender(blockKey, hidden) {
|
2026-04-14 22:25:16 +08:00
|
|
|
|
[container, container2].forEach(targetContainer => {
|
|
|
|
|
|
if (!targetContainer) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:31:11 +08:00
|
|
|
|
targetContainer.querySelectorAll('[data-block-key]').forEach(node => {
|
|
|
|
|
|
if (node.dataset.blockKey === blockKey) {
|
|
|
|
|
|
if (hidden) {
|
|
|
|
|
|
node.dataset.blockHidden = '1';
|
|
|
|
|
|
node.style.display = 'none';
|
|
|
|
|
|
} else if (node.dataset.blockHidden === '1') {
|
|
|
|
|
|
node.removeAttribute('data-block-hidden');
|
|
|
|
|
|
node.style.display = '';
|
|
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-04-14 22:31:11 +08:00
|
|
|
|
|
|
|
|
|
|
if (!hidden && autoScroll) {
|
|
|
|
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
|
|
container2.scrollTop = container2.scrollHeight;
|
|
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换系统播报屏蔽菜单的显示状态。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Event} event 点击事件
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleBlockMenu(event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const menu = document.getElementById('block-menu');
|
|
|
|
|
|
const welcomeMenu = document.getElementById('welcome-menu');
|
|
|
|
|
|
const adminMenu = document.getElementById('admin-menu');
|
|
|
|
|
|
|
|
|
|
|
|
if (!menu) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (welcomeMenu) {
|
|
|
|
|
|
welcomeMenu.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (adminMenu) {
|
|
|
|
|
|
adminMenu.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
syncBlockedSystemSenderCheckboxes();
|
|
|
|
|
|
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 更新指定系统播报项的屏蔽状态,并在勾选后立即清理当前窗口。
|
|
|
|
|
|
*
|
2026-04-14 22:31:11 +08:00
|
|
|
|
* @param {string} sender 系统播报发送者/规则键
|
2026-04-14 22:25:16 +08:00
|
|
|
|
* @param {boolean} blocked 是否屏蔽
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleBlockedSystemSender(sender, blocked) {
|
|
|
|
|
|
if (!BLOCKABLE_SYSTEM_SENDERS.includes(sender)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (blocked) {
|
|
|
|
|
|
blockedSystemSenders.add(sender);
|
2026-04-14 22:31:11 +08:00
|
|
|
|
// 勾选后立刻隐藏聊天室窗口内已显示的对应播报内容。
|
|
|
|
|
|
setRenderedMessagesVisibilityBySender(sender, true);
|
2026-04-14 22:25:16 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
blockedSystemSenders.delete(sender);
|
2026-04-14 22:31:11 +08:00
|
|
|
|
// 取消勾选后立即恢复先前被隐藏的对应播报内容。
|
|
|
|
|
|
setRenderedMessagesVisibilityBySender(sender, false);
|
2026-04-14 22:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
persistBlockedSystemSenders();
|
|
|
|
|
|
syncBlockedSystemSenderCheckboxes();
|
2026-04-14 22:48:29 +08:00
|
|
|
|
void saveChatPreferences();
|
2026-04-14 22:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
syncBlockedSystemSenderCheckboxes();
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-04-11 15:44:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 转义会员横幅文本,避免横幅层被注入 HTML。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function escapePresenceText(text) {
|
|
|
|
|
|
return escapeHtml(String(text ?? '')).replace(/\n/g, '<br>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据不同的会员横幅风格返回渐变与光影配置。
|
2026-04-12 14:32:44 +08:00
|
|
|
|
* 已调优为更明亮、喜庆的配色方案。
|
2026-04-11 15:44:30 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function getVipPresenceStyleConfig(style, color) {
|
|
|
|
|
|
const fallback = color || '#f59e0b';
|
|
|
|
|
|
|
|
|
|
|
|
const map = {
|
|
|
|
|
|
aurora: {
|
2026-04-12 14:32:44 +08:00
|
|
|
|
// 鎏光星幕:金黄到明黄渐变,极具喜庆感
|
|
|
|
|
|
gradient: `linear-gradient(135deg, #f59e0b, #fbbf24, #fef3c7)`,
|
|
|
|
|
|
glow: `rgba(251, 191, 36, 0.4)`,
|
|
|
|
|
|
accent: '#78350f',
|
2026-04-11 15:44:30 +08:00
|
|
|
|
},
|
|
|
|
|
|
storm: {
|
2026-04-12 14:32:44 +08:00
|
|
|
|
// 雷霆风暴:明亮的湖蓝到浅蓝,清爽亮丽
|
|
|
|
|
|
gradient: `linear-gradient(135deg, #0ea5e9, #7dd3fc, #f0f9ff)`,
|
|
|
|
|
|
glow: `rgba(125, 211, 252, 0.4)`,
|
|
|
|
|
|
accent: '#0369a1',
|
2026-04-11 15:44:30 +08:00
|
|
|
|
},
|
|
|
|
|
|
royal: {
|
2026-04-12 14:32:44 +08:00
|
|
|
|
// 王者金辉:深金到亮金,尊贵大气
|
|
|
|
|
|
gradient: `linear-gradient(135deg, #d97706, #fcd34d, #fffbeb)`,
|
|
|
|
|
|
glow: `rgba(252, 211, 77, 0.4)`,
|
|
|
|
|
|
accent: '#92400e',
|
2026-04-11 15:44:30 +08:00
|
|
|
|
},
|
|
|
|
|
|
cosmic: {
|
2026-04-12 14:32:44 +08:00
|
|
|
|
// 星穹幻彩:玫红到粉色渐变,活泼吉利
|
|
|
|
|
|
gradient: `linear-gradient(135deg, #db2777, #f472b6, #fdf2f8)`,
|
|
|
|
|
|
glow: `rgba(244, 114, 182, 0.4)`,
|
|
|
|
|
|
accent: '#9d174d',
|
2026-04-11 15:44:30 +08:00
|
|
|
|
},
|
|
|
|
|
|
farewell: {
|
2026-04-12 14:32:44 +08:00
|
|
|
|
// 告别暮光:橙红到暖黄,温馨亮堂
|
|
|
|
|
|
gradient: `linear-gradient(135deg, #ea580c, #fb923c, #fff7ed)`,
|
|
|
|
|
|
glow: `rgba(251, 146, 60, 0.4)`,
|
|
|
|
|
|
accent: '#9a3412',
|
2026-04-11 15:44:30 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return map[style] || map.aurora;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 显示会员进退场豪华横幅。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function showVipPresenceBanner(payload) {
|
|
|
|
|
|
if (!payload || !payload.presence_text) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const existing = document.getElementById('vip-presence-banner');
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
existing.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styleConfig = getVipPresenceStyleConfig(payload.presence_banner_style, payload.presence_color);
|
2026-04-12 23:25:38 +08:00
|
|
|
|
const bannerTypeLabel = payload.presence_type === 'leave'
|
|
|
|
|
|
? '离场提示'
|
|
|
|
|
|
: (payload.presence_type === 'purchase' ? '开通喜报' : '闪耀登场');
|
2026-04-11 15:44:30 +08:00
|
|
|
|
const banner = document.createElement('div');
|
|
|
|
|
|
banner.id = 'vip-presence-banner';
|
|
|
|
|
|
banner.className = 'vip-presence-banner';
|
|
|
|
|
|
banner.innerHTML = `
|
|
|
|
|
|
<div class="vip-presence-banner__glow" style="background:${styleConfig.glow};"></div>
|
|
|
|
|
|
<div class="vip-presence-banner__card" style="background:${styleConfig.gradient}; border-color:${payload.presence_color || '#fff'};">
|
|
|
|
|
|
<div class="vip-presence-banner__meta">
|
|
|
|
|
|
<span class="vip-presence-banner__icon">${escapeHtml(payload.presence_icon || '👑')}</span>
|
|
|
|
|
|
<span class="vip-presence-banner__level">${escapeHtml(payload.presence_level_name || '尊贵会员')}</span>
|
2026-04-12 23:25:38 +08:00
|
|
|
|
<span class="vip-presence-banner__type">${bannerTypeLabel}</span>
|
2026-04-11 15:44:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="vip-presence-banner__text" style="color:${styleConfig.accent};">${escapePresenceText(payload.presence_text)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(banner);
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
banner.classList.add('is-leaving');
|
|
|
|
|
|
setTimeout(() => banner.remove(), 700);
|
|
|
|
|
|
}, 4200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.showVipPresenceBanner = showVipPresenceBanner;
|
|
|
|
|
|
|
2026-04-12 14:04:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 判断图片消息是否已经超过前端允许展示的保留期。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function isExpiredChatImageMessage(msg) {
|
|
|
|
|
|
if (!msg) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.message_type === 'expired_image') {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.message_type !== 'image') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!msg.image_url || !msg.image_thumb_url) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const retentionDays = parseInt(window.chatContext?.chatImageRetentionDays || 3, 10);
|
|
|
|
|
|
const sentAtText = String(msg.sent_at || '').replace(' ', 'T');
|
|
|
|
|
|
const sentAt = sentAtText ? new Date(sentAtText) : null;
|
|
|
|
|
|
|
|
|
|
|
|
if (!sentAt || Number.isNaN(sentAt.getTime())) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Date.now() >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 构建普通聊天消息的正文区域,支持缩略图与过期占位渲染。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildChatMessageContent(msg, fontColor) {
|
|
|
|
|
|
const rawContent = msg.content || '';
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.message_type === 'image' && !isExpiredChatImageMessage(msg)) {
|
|
|
|
|
|
const fullUrl = escapeHtml(msg.image_url || '');
|
|
|
|
|
|
const thumbUrl = escapeHtml(msg.image_thumb_url || '');
|
|
|
|
|
|
const imageName = escapeHtml(msg.image_original_name || '聊天图片');
|
|
|
|
|
|
const captionHtml = rawContent ?
|
|
|
|
|
|
`<span style="display:inline-block; max-width:220px; color:${fontColor}; line-height:1.55;">${rawContent}</span>` :
|
|
|
|
|
|
'';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
|
|
|
|
|
|
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}"
|
|
|
|
|
|
onclick="openChatImageLightbox(this.dataset.full, this.dataset.alt); return false;"
|
|
|
|
|
|
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
|
|
|
|
|
|
<img src="${thumbUrl}" alt="${imageName}"
|
|
|
|
|
|
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
|
|
|
|
|
|
</a>
|
|
|
|
|
|
${captionHtml}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.message_type === 'expired_image' || isExpiredChatImageMessage(msg)) {
|
|
|
|
|
|
const captionHtml = rawContent ?
|
|
|
|
|
|
`<span style="display:inline-block; color:${fontColor}; line-height:1.55;">${rawContent}</span>` :
|
|
|
|
|
|
'';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
|
|
|
|
|
|
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
|
|
|
|
|
|
${captionHtml}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return rawContent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// ── Tab 切换 ──────────────────────────────────────
|
2026-03-17 20:54:43 +08:00
|
|
|
|
let _roomsRefreshTimer = null;
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
function switchTab(tab) {
|
2026-03-12 13:24:47 +08:00
|
|
|
|
// 切换名单/房间 面板
|
|
|
|
|
|
['users', 'rooms'].forEach(t => {
|
2026-02-26 21:10:34 +08:00
|
|
|
|
document.getElementById('panel-' + t).style.display = t === tab ? 'block' : 'none';
|
2026-03-03 14:46:22 +08:00
|
|
|
|
document.getElementById('tab-' + t)?.classList.toggle('active', t === tab);
|
2026-02-26 21:10:34 +08:00
|
|
|
|
});
|
2026-03-17 20:54:43 +08:00
|
|
|
|
// 房间 Tab:立即拉取 + 每 30 秒自动刷新在线人数
|
2026-03-03 14:46:22 +08:00
|
|
|
|
if (tab === 'rooms') {
|
|
|
|
|
|
loadRoomsOnlineStatus();
|
2026-03-17 20:54:43 +08:00
|
|
|
|
clearInterval(_roomsRefreshTimer);
|
|
|
|
|
|
_roomsRefreshTimer = setInterval(loadRoomsOnlineStatus, 30000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearInterval(_roomsRefreshTimer);
|
|
|
|
|
|
_roomsRefreshTimer = null;
|
2026-03-03 14:46:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 拉取所有房间在线人数并渲染到右侧面板
|
|
|
|
|
|
*/
|
|
|
|
|
|
const _currentRoomId = {{ $room->id }};
|
|
|
|
|
|
|
|
|
|
|
|
function loadRoomsOnlineStatus() {
|
|
|
|
|
|
const container = document.getElementById('rooms-online-list');
|
|
|
|
|
|
if (!container) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fetch('{{ route('chat.rooms-online-status') }}')
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
if (!data.rooms || !data.rooms.length) {
|
|
|
|
|
|
container.innerHTML =
|
|
|
|
|
|
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-19 14:43:02 +08:00
|
|
|
|
const roomRows = data.rooms.map(room => {
|
|
|
|
|
|
const roomId = Number.parseInt(room.id, 10);
|
|
|
|
|
|
if (!Number.isInteger(roomId)) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isCurrent = roomId === _currentRoomId;
|
2026-03-03 14:46:22 +08:00
|
|
|
|
const closed = !room.door_open;
|
2026-04-19 14:43:02 +08:00
|
|
|
|
const safeRoomName = escapeHtml(String(room.name ?? ''));
|
|
|
|
|
|
const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0);
|
2026-03-03 14:46:22 +08:00
|
|
|
|
const bg = isCurrent ? '#ecf4ff' : '#fff';
|
|
|
|
|
|
const border = isCurrent ? '#aac5f0' : '#e0eaf5';
|
|
|
|
|
|
const nameColor = isCurrent ? '#336699' : (closed ? '#bbb' : '#444');
|
2026-04-19 14:43:02 +08:00
|
|
|
|
const badge = safeOnlineCount > 0 ?
|
|
|
|
|
|
`<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 5px;font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${safeOnlineCount} 人</span>` :
|
2026-03-12 13:32:35 +08:00
|
|
|
|
`<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 5px;font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
|
2026-03-03 14:46:22 +08:00
|
|
|
|
const currentTag = isCurrent ?
|
|
|
|
|
|
`<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>` :
|
|
|
|
|
|
'';
|
2026-04-19 14:43:02 +08:00
|
|
|
|
const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
|
2026-03-03 14:46:22 +08:00
|
|
|
|
|
|
|
|
|
|
return `<div ${clickHandler}
|
|
|
|
|
|
style="display:flex;align-items:center;justify-content:space-between;
|
|
|
|
|
|
padding:5px 8px;margin:2px 3px;border-radius:5px;
|
|
|
|
|
|
border:1px solid ${border};background:${bg};
|
|
|
|
|
|
cursor:${isCurrent ? 'default' : 'pointer'};
|
|
|
|
|
|
transition:background .15s;"
|
|
|
|
|
|
onmouseover="if(${!isCurrent}) this.style.background='#ddeeff';"
|
|
|
|
|
|
onmouseout="this.style.background='${bg}';">
|
2026-03-12 13:32:35 +08:00
|
|
|
|
<span style="color:${nameColor};font-size:11px;font-weight:${isCurrent?'bold':'normal'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:4px;">
|
2026-04-19 14:43:02 +08:00
|
|
|
|
${safeRoomName}${currentTag}
|
2026-03-03 14:46:22 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
${badge}
|
|
|
|
|
|
</div>`;
|
2026-04-19 14:43:02 +08:00
|
|
|
|
}).filter(Boolean).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = roomRows ||
|
|
|
|
|
|
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
|
2026-03-03 14:46:22 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
container.innerHTML =
|
|
|
|
|
|
'<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
|
|
|
|
|
|
});
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 21:12:14 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 欢迎语快捷菜单 ──────────────────────────────────────
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换欢迎语下拉浮层的显示/隐藏
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleWelcomeMenu(event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const menu = document.getElementById('welcome-menu');
|
2026-04-12 16:54:25 +08:00
|
|
|
|
const adminMenu = document.getElementById('admin-menu');
|
2026-04-14 22:25:16 +08:00
|
|
|
|
const blockMenu = document.getElementById('block-menu');
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (!menu) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-12 16:54:25 +08:00
|
|
|
|
if (adminMenu) {
|
|
|
|
|
|
adminMenu.style.display = 'none';
|
2026-04-12 16:24:48 +08:00
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
if (blockMenu) {
|
|
|
|
|
|
blockMenu.style.display = 'none';
|
|
|
|
|
|
}
|
2026-04-12 16:24:48 +08:00
|
|
|
|
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-12 16:54:25 +08:00
|
|
|
|
* 切换顶部管理菜单的显示状态。
|
2026-04-12 16:24:48 +08:00
|
|
|
|
*/
|
2026-04-12 16:54:25 +08:00
|
|
|
|
function toggleAdminMenu(event) {
|
2026-04-12 16:24:48 +08:00
|
|
|
|
event.stopPropagation();
|
2026-04-12 16:54:25 +08:00
|
|
|
|
const menu = document.getElementById('admin-menu');
|
2026-04-12 16:24:48 +08:00
|
|
|
|
const welcomeMenu = document.getElementById('welcome-menu');
|
2026-04-14 22:25:16 +08:00
|
|
|
|
const blockMenu = document.getElementById('block-menu');
|
2026-04-12 16:24:48 +08:00
|
|
|
|
if (!menu) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (welcomeMenu) {
|
|
|
|
|
|
welcomeMenu.style.display = 'none';
|
|
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
if (blockMenu) {
|
|
|
|
|
|
blockMenu.style.display = 'none';
|
|
|
|
|
|
}
|
2026-03-17 21:12:14 +08:00
|
|
|
|
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 16:54:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 执行管理菜单中的快捷操作,并在执行前关闭菜单。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} action 管理动作类型
|
|
|
|
|
|
*/
|
|
|
|
|
|
function runAdminAction(action) {
|
|
|
|
|
|
const menu = document.getElementById('admin-menu');
|
|
|
|
|
|
if (menu) {
|
|
|
|
|
|
menu.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (action) {
|
|
|
|
|
|
case 'announcement':
|
|
|
|
|
|
promptAnnouncement();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'announce-message':
|
|
|
|
|
|
promptAnnounceMessage();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'admin-clear':
|
|
|
|
|
|
adminClearScreen();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'red-packet':
|
|
|
|
|
|
sendRedPacket();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'loss-cover':
|
|
|
|
|
|
openAdminBaccaratLossCoverModal();
|
|
|
|
|
|
break;
|
2026-04-21 17:14:12 +08:00
|
|
|
|
case 'refresh-all':
|
|
|
|
|
|
refreshAllBrowsers();
|
|
|
|
|
|
break;
|
2026-04-12 16:54:25 +08:00
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 16:24:48 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 选择特效后关闭菜单,并沿用原有管理员特效触发逻辑。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} type 特效类型
|
|
|
|
|
|
*/
|
|
|
|
|
|
function selectEffect(type) {
|
2026-04-12 16:54:25 +08:00
|
|
|
|
const menu = document.getElementById('admin-menu');
|
2026-04-12 16:24:48 +08:00
|
|
|
|
if (menu) {
|
|
|
|
|
|
menu.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
triggerEffect(type);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:14:12 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 站长通知当前房间所有在线用户刷新页面。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function refreshAllBrowsers() {
|
|
|
|
|
|
if (!window.chatContext?.isSiteOwner || !window.chatContext?.refreshAllUrl) {
|
|
|
|
|
|
window.chatDialog?.alert('仅站长可执行全员刷新。', '无权限', '#cc4444');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const confirmed = await window.chatDialog?.confirm(
|
|
|
|
|
|
'确定通知当前房间所有在线用户刷新页面吗?\n适用于功能更新后强制同步最新按钮与权限状态。',
|
|
|
|
|
|
'♻️ 刷新全员',
|
|
|
|
|
|
'#0f766e'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!confirmed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(window.chatContext.refreshAllUrl, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
room_id: window.chatContext.roomId,
|
|
|
|
|
|
reason: '功能更新,站长要求刷新页面',
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.status === 'success') {
|
|
|
|
|
|
window.chatToast?.show({
|
|
|
|
|
|
title: '已发送刷新通知',
|
|
|
|
|
|
message: data.message,
|
|
|
|
|
|
icon: '♻️',
|
|
|
|
|
|
color: '#0f766e',
|
|
|
|
|
|
duration: 3500,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.chatDialog?.alert(data.message || '发送刷新通知失败', '操作失败', '#cc4444');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
window.chatDialog?.alert('网络异常,请稍后再试', '错误', '#cc4444');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 21:12:14 +08:00
|
|
|
|
/**
|
2026-03-17 21:19:38 +08:00
|
|
|
|
* 将选中的欢迎语模板填入输入框,{name} 替换为当前选中的聊天对象,
|
|
|
|
|
|
* 并在前面加上「部门 职务 姓名:」前缀,然后自动发送
|
2026-03-17 21:12:14 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param {string} tpl 欢迎语模板,含 {name} 占位符
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sendWelcomeTpl(tpl) {
|
|
|
|
|
|
const toUser = document.getElementById('to_user')?.value || '大家';
|
2026-03-17 21:26:57 +08:00
|
|
|
|
const name = toUser === '大家' ? '大家' : toUser;
|
2026-03-17 21:19:38 +08:00
|
|
|
|
const prefix = window.chatContext?.welcomePrefix || window.chatContext?.username || '';
|
2026-03-17 21:26:57 +08:00
|
|
|
|
const body = tpl.replace(/\{name\}/g, name);
|
2026-03-17 21:19:38 +08:00
|
|
|
|
// 拼接格式:「部门 职务 姓名:欢迎语」
|
2026-03-17 21:26:57 +08:00
|
|
|
|
const msg = `${prefix}:${body}`;
|
2026-03-17 21:12:14 +08:00
|
|
|
|
|
|
|
|
|
|
const input = document.getElementById('content');
|
|
|
|
|
|
if (input) {
|
|
|
|
|
|
input.value = msg;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const menu = document.getElementById('welcome-menu');
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (menu) {
|
|
|
|
|
|
menu.style.display = 'none';
|
|
|
|
|
|
}
|
2026-03-17 21:19:38 +08:00
|
|
|
|
|
2026-03-17 21:24:31 +08:00
|
|
|
|
// 临时把 action 设为「欢迎」,让消息在聊天窗口以瓦蓝边框样式显示
|
|
|
|
|
|
const actionSel = document.getElementById('action');
|
|
|
|
|
|
const prevAction = actionSel?.value || '';
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (actionSel) {
|
|
|
|
|
|
actionSel.value = '欢迎';
|
|
|
|
|
|
}
|
2026-03-17 21:24:31 +08:00
|
|
|
|
|
2026-03-17 21:19:38 +08:00
|
|
|
|
// 自动触发发送
|
2026-03-17 21:24:31 +08:00
|
|
|
|
sendMessage(null).finally(() => {
|
|
|
|
|
|
// 发完后恢复原来的 action
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (actionSel) {
|
|
|
|
|
|
actionSel.value = prevAction;
|
|
|
|
|
|
}
|
2026-03-17 21:24:31 +08:00
|
|
|
|
});
|
2026-03-17 21:12:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 点击页面任意位置,关闭欢迎语浮层
|
2026-03-17 21:26:57 +08:00
|
|
|
|
document.addEventListener('click', function() {
|
2026-03-17 21:12:14 +08:00
|
|
|
|
const menu = document.getElementById('welcome-menu');
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (menu) {
|
|
|
|
|
|
menu.style.display = 'none';
|
|
|
|
|
|
}
|
2026-04-12 16:24:48 +08:00
|
|
|
|
|
2026-04-12 16:54:25 +08:00
|
|
|
|
const adminMenu = document.getElementById('admin-menu');
|
|
|
|
|
|
if (adminMenu) {
|
|
|
|
|
|
adminMenu.style.display = 'none';
|
2026-04-12 16:24:48 +08:00
|
|
|
|
}
|
2026-04-14 22:25:16 +08:00
|
|
|
|
|
|
|
|
|
|
const blockMenu = document.getElementById('block-menu');
|
|
|
|
|
|
if (blockMenu) {
|
|
|
|
|
|
blockMenu.style.display = 'none';
|
|
|
|
|
|
}
|
2026-03-17 21:12:14 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-12 13:20:26 +08:00
|
|
|
|
// ── 动作选择 ──────────────────────────────────────
|
2026-03-17 17:49:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 设置发言动作并聚焦输入框
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} act 动作名称
|
|
|
|
|
|
*/
|
2026-03-12 13:20:26 +08:00
|
|
|
|
function setAction(act) {
|
|
|
|
|
|
document.getElementById('action').value = act;
|
|
|
|
|
|
switchTab('users');
|
|
|
|
|
|
document.getElementById('content').focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// ── 自动滚屏 ──────────────────────────────────────
|
2026-02-28 11:22:18 +08:00
|
|
|
|
const autoScrollEl = document.getElementById('auto_scroll');
|
|
|
|
|
|
if (autoScrollEl) {
|
|
|
|
|
|
autoScrollEl.addEventListener('change', function() {
|
|
|
|
|
|
autoScroll = this.checked;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 滚动到底部 ───────────────────────────────────
|
2026-03-17 17:49:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 将公聊窗口滚动到最新消息(受 autoScroll 开关控制)
|
|
|
|
|
|
*/
|
2026-02-26 21:10:34 +08:00
|
|
|
|
function scrollToBottom() {
|
|
|
|
|
|
if (autoScroll) {
|
|
|
|
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 10:37:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把小气泡提示定位到图标旁边,不侵入右侧名单结构。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {HTMLElement} trigger 当前悬浮的图标元素
|
|
|
|
|
|
*/
|
|
|
|
|
|
function positionHoverTooltip(trigger) {
|
|
|
|
|
|
if (!hoverTooltip || !trigger) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const offset = 10;
|
|
|
|
|
|
const rect = trigger.getBoundingClientRect();
|
|
|
|
|
|
const tooltipWidth = hoverTooltip.offsetWidth;
|
|
|
|
|
|
const tooltipHeight = hoverTooltip.offsetHeight;
|
|
|
|
|
|
const fitsRight = rect.right + offset + tooltipWidth <= window.innerWidth - 8;
|
|
|
|
|
|
const side = fitsRight ? 'right' : 'left';
|
|
|
|
|
|
const nextLeft = fitsRight
|
|
|
|
|
|
? rect.right + offset
|
|
|
|
|
|
: Math.max(8, rect.left - tooltipWidth - offset);
|
|
|
|
|
|
const nextTop = Math.min(
|
|
|
|
|
|
Math.max(8, rect.top + (rect.height - tooltipHeight) / 2),
|
|
|
|
|
|
window.innerHeight - tooltipHeight - 8
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
hoverTooltip.dataset.side = side;
|
|
|
|
|
|
hoverTooltip.style.left = `${nextLeft}px`;
|
|
|
|
|
|
hoverTooltip.style.top = `${nextTop}px`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 显示图标旁边的小气泡文字提示。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {HTMLElement|null} trigger 当前悬浮的图标元素
|
|
|
|
|
|
*/
|
|
|
|
|
|
function showHoverTooltip(trigger) {
|
|
|
|
|
|
if (!hoverTooltip || !trigger) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tooltipText = trigger.dataset.instantTooltip || '';
|
|
|
|
|
|
if (!tooltipText) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
activeTooltipTrigger = trigger;
|
|
|
|
|
|
hoverTooltip.textContent = tooltipText;
|
|
|
|
|
|
hoverTooltip.style.display = 'block';
|
|
|
|
|
|
positionHoverTooltip(trigger);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 隐藏图标提示气泡。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function hideHoverTooltip() {
|
|
|
|
|
|
if (!hoverTooltip) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
activeTooltipTrigger = null;
|
|
|
|
|
|
hoverTooltip.style.display = 'none';
|
|
|
|
|
|
hoverTooltip.textContent = '';
|
|
|
|
|
|
delete hoverTooltip.dataset.side;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 通过事件委托处理动态生成的徽章提示,确保 hover 后立刻显示。
|
|
|
|
|
|
document.addEventListener('mouseover', (event) => {
|
|
|
|
|
|
const trigger = event.target.closest('.user-badge-icon[data-instant-tooltip]');
|
|
|
|
|
|
if (!trigger) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showHoverTooltip(trigger);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mouseout', (event) => {
|
|
|
|
|
|
const trigger = event.target.closest('.user-badge-icon[data-instant-tooltip]');
|
|
|
|
|
|
if (!trigger || trigger !== activeTooltipTrigger) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (event.relatedTarget && trigger.contains(event.relatedTarget)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
hideHoverTooltip();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('scroll', () => {
|
|
|
|
|
|
if (activeTooltipTrigger) {
|
|
|
|
|
|
positionHoverTooltip(activeTooltipTrigger);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, true);
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
|
if (activeTooltipTrigger) {
|
|
|
|
|
|
positionHoverTooltip(activeTooltipTrigger);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// ── 渲染在线人员列表(支持排序) ──────────────────
|
2026-03-17 17:49:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 核心渲染函数:将在线用户渲染到指定容器(桌面端名单区和手机端抽屉共用)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {HTMLElement} targetContainer 目标 DOM 容器
|
|
|
|
|
|
* @param {string} sortBy 排序方式:'default' | 'name' | 'level'
|
|
|
|
|
|
* @param {string} keyword 搜索关键词(小写)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function _renderUserListToContainer(targetContainer, sortBy, keyword) {
|
|
|
|
|
|
if (!targetContainer) return;
|
|
|
|
|
|
targetContainer.innerHTML = '';
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 在列表顶部添加"大家"条目(原版风格)
|
|
|
|
|
|
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 = '大家';
|
|
|
|
|
|
};
|
2026-03-17 17:49:14 +08:00
|
|
|
|
targetContainer.appendChild(allDiv);
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-26 11:15:11 +08:00
|
|
|
|
// ── AI 小助手(已在 onlineUsers 中动态维护,移除硬编码)──
|
2026-02-26 21:30:07 +08:00
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// 构建用户数组并排序
|
|
|
|
|
|
let userArr = [];
|
|
|
|
|
|
for (let username in onlineUsers) {
|
|
|
|
|
|
userArr.push({
|
|
|
|
|
|
username,
|
|
|
|
|
|
...onlineUsers[username]
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (sortBy === 'name') {
|
|
|
|
|
|
userArr.sort((a, b) => a.username.localeCompare(b.username, 'zh'));
|
|
|
|
|
|
} else if (sortBy === 'level') {
|
|
|
|
|
|
userArr.sort((a, b) => (b.user_level || 0) - (a.user_level || 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
userArr.forEach(user => {
|
|
|
|
|
|
const username = user.username;
|
2026-03-17 17:49:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 搜索过滤
|
|
|
|
|
|
if (keyword && !username.toLowerCase().includes(keyword)) return;
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
let item = document.createElement('div');
|
|
|
|
|
|
item.className = 'user-item';
|
|
|
|
|
|
item.dataset.username = username;
|
|
|
|
|
|
|
2026-04-02 17:01:13 +08:00
|
|
|
|
const headface = (user.headface || '1.gif');
|
2026-03-17 17:49:14 +08:00
|
|
|
|
const headImgSrc = headface.startsWith('storage/') ? '/' + headface : '/images/headface/' +
|
2026-03-17 21:26:57 +08:00
|
|
|
|
headface;
|
2026-02-28 23:44:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 徽章优先级:职务图标 > 管理员 > VIP
|
2026-02-26 21:30:07 +08:00
|
|
|
|
let badges = '';
|
2026-02-28 23:44:38 +08:00
|
|
|
|
if (user.position_icon) {
|
|
|
|
|
|
const posTitle = (user.position_name || '在职') + ' · ' + username;
|
2026-04-22 10:18:49 +08:00
|
|
|
|
const safePosTitle = escapeHtml(String(posTitle));
|
|
|
|
|
|
const safePositionIcon = escapeHtml(String(user.position_icon || '🎖️'));
|
2026-02-28 23:44:38 +08:00
|
|
|
|
badges +=
|
2026-04-22 10:18:49 +08:00
|
|
|
|
`<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
|
2026-02-28 23:44:38 +08:00
|
|
|
|
} else if (user.is_admin) {
|
2026-04-22 10:18:49 +08:00
|
|
|
|
badges += `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
|
2026-02-27 10:14:56 +08:00
|
|
|
|
} else if (user.vip_icon) {
|
2026-02-26 21:30:07 +08:00
|
|
|
|
const vipColor = user.vip_color || '#f59e0b';
|
2026-04-22 10:18:49 +08:00
|
|
|
|
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
|
|
|
|
|
|
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
|
2026-02-26 21:30:07 +08:00
|
|
|
|
badges +=
|
2026-04-22 10:18:49 +08:00
|
|
|
|
`<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
|
2026-02-26 21:30:07 +08:00
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-02-26 22:59:41 +08:00
|
|
|
|
// 女生名字使用玫粉色
|
|
|
|
|
|
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
|
2026-02-26 21:10:34 +08:00
|
|
|
|
item.innerHTML = `
|
2026-03-12 15:26:54 +08:00
|
|
|
|
<img class="user-head" src="${headImgSrc}" onerror="this.src='/images/headface/1.gif'">
|
2026-02-26 22:59:41 +08:00
|
|
|
|
<span class="user-name" style="${nameColor}">${username}</span>${badges}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
`;
|
|
|
|
|
|
|
2026-03-17 17:59:21 +08:00
|
|
|
|
// 单击/双击互斥:单击延迟 250ms 执行,双击取消单击定时器后直接执行双击逻辑
|
|
|
|
|
|
let _clickTimer = null;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
item.onclick = () => {
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (_clickTimer) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-17 17:59:21 +08:00
|
|
|
|
_clickTimer = setTimeout(() => {
|
|
|
|
|
|
_clickTimer = null;
|
|
|
|
|
|
toUserSelect.value = username;
|
|
|
|
|
|
// 手机端:点击名字时关闭名单抽屉
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (typeof closeMobileDrawer === 'function') {
|
|
|
|
|
|
closeMobileDrawer();
|
|
|
|
|
|
}
|
2026-03-17 17:59:21 +08:00
|
|
|
|
document.getElementById('content').focus();
|
|
|
|
|
|
}, 250);
|
|
|
|
|
|
};
|
2026-03-18 20:51:57 +08:00
|
|
|
|
// 双击 / 手机端双触发 打开用户名片弹窗
|
|
|
|
|
|
const _openCardFromList = () => {
|
|
|
|
|
|
if (_clickTimer) { clearTimeout(_clickTimer); _clickTimer = null; }
|
|
|
|
|
|
if (typeof closeMobileDrawer === 'function') { closeMobileDrawer(); }
|
2026-03-17 17:59:21 +08:00
|
|
|
|
openUserCard(username);
|
2026-02-26 21:10:34 +08:00
|
|
|
|
};
|
2026-03-18 20:51:57 +08:00
|
|
|
|
item.ondblclick = _openCardFromList;
|
|
|
|
|
|
// 手机端:检测 300ms 内两次 touchend 触发双击
|
|
|
|
|
|
let _tapTime = 0;
|
|
|
|
|
|
item.addEventListener('touchend', (e) => {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (now - _tapTime < 300) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
_openCardFromList();
|
|
|
|
|
|
_tapTime = 0;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_tapTime = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { passive: false });
|
2026-03-17 17:49:14 +08:00
|
|
|
|
targetContainer.appendChild(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-17 17:49:14 +08:00
|
|
|
|
function renderUserList() {
|
|
|
|
|
|
userList.innerHTML = '';
|
|
|
|
|
|
toUserSelect.innerHTML = '<option value="大家">大家</option>';
|
|
|
|
|
|
|
|
|
|
|
|
// 获取排序方式和搜索词
|
|
|
|
|
|
const sortSelect = document.getElementById('user-sort-select');
|
|
|
|
|
|
const sortBy = sortSelect ? sortSelect.value : 'default';
|
|
|
|
|
|
const searchInput = document.getElementById('user-search-input');
|
|
|
|
|
|
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : '';
|
|
|
|
|
|
|
|
|
|
|
|
// 调用核心渲染(桌面端名单容器)
|
|
|
|
|
|
_renderUserListToContainer(userList, sortBy, keyword);
|
|
|
|
|
|
|
2026-03-26 11:15:11 +08:00
|
|
|
|
// 下拉框里如果 AI在场,可以直接选
|
2026-03-17 17:49:14 +08:00
|
|
|
|
for (let username in onlineUsers) {
|
2026-02-26 21:10:34 +08:00
|
|
|
|
if (username !== window.chatContext.username) {
|
|
|
|
|
|
let option = document.createElement('option');
|
|
|
|
|
|
option.value = username;
|
2026-03-26 11:15:11 +08:00
|
|
|
|
let text = username;
|
|
|
|
|
|
if (username === 'AI小班长') {
|
|
|
|
|
|
text = '🤖 AI小班长';
|
|
|
|
|
|
}
|
|
|
|
|
|
option.textContent = text;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
toUserSelect.appendChild(option);
|
|
|
|
|
|
}
|
2026-03-17 17:49:14 +08:00
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-17 17:49:14 +08:00
|
|
|
|
const count = Object.keys(onlineUsers).length;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
onlineCount.innerText = count;
|
|
|
|
|
|
onlineCountBottom.innerText = count;
|
|
|
|
|
|
const footer = document.getElementById('online-count-footer');
|
2026-03-17 21:26:57 +08:00
|
|
|
|
if (footer) {
|
|
|
|
|
|
footer.innerText = count;
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-17 17:49:14 +08:00
|
|
|
|
// 派发用户列表更新事件,供手机端抽屉同步
|
|
|
|
|
|
window.dispatchEvent(new Event('chatroom:users-updated'));
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 搜索/过滤用户列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
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';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 00:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-17 17:49:14 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function appendMessage(msg) {
|
2026-02-28 11:20:34 +08:00
|
|
|
|
// 记录拉取到的最大消息ID,用于本地清屏功能
|
|
|
|
|
|
if (msg && msg.id > _maxMsgId) {
|
|
|
|
|
|
_maxMsgId = msg.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
const isMe = msg.from_user === window.chatContext.username;
|
|
|
|
|
|
const fontColor = msg.font_color || '#000000';
|
2026-04-14 22:31:11 +08:00
|
|
|
|
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
|
|
|
|
|
|
const shouldHideByBlock = blockRuleKey ? blockedSystemSenders.has(blockRuleKey) : false;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'msg-line';
|
2026-04-14 22:25:16 +08:00
|
|
|
|
if (msg?.from_user) {
|
|
|
|
|
|
div.dataset.fromUser = msg.from_user;
|
|
|
|
|
|
}
|
2026-04-14 22:31:11 +08:00
|
|
|
|
if (blockRuleKey) {
|
|
|
|
|
|
div.dataset.blockKey = blockRuleKey;
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
const timeStr = msg.sent_at || '';
|
2026-02-27 11:09:36 +08:00
|
|
|
|
let timeStrOverride = false;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-02-26 21:57:54 +08:00
|
|
|
|
// 系统用户名列表(不可被选为聊天对象)
|
2026-04-17 15:27:40 +08:00
|
|
|
|
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', '送花播报', '系统', '欢迎', '系统播报', '神秘箱子'];
|
2026-02-27 12:58:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对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];
|
2026-04-19 14:43:02 +08:00
|
|
|
|
if (!info) return `${fromHtml}对${toHtml}${escapeHtml(String(action || ''))}${verb}:`;
|
2026-02-27 12:58:31 +08:00
|
|
|
|
if (info.type === 'emotion') return `${fromHtml}${info.word}对${toHtml}${verb}:`;
|
|
|
|
|
|
return `${fromHtml}${info.word}${toHtml},${verb}:`;
|
|
|
|
|
|
};
|
2026-03-16 15:43:27 +08:00
|
|
|
|
// 判断 【】 内的内容是否是游戏/活动标签而非真实用户名
|
|
|
|
|
|
// 规则:命中已知游戏前缀,或内容含空格(如「双色球 第012期 开奖」)
|
|
|
|
|
|
const isGameLabel = (name) => {
|
|
|
|
|
|
const gamePrefixes = ['五子棋', '双色球', '钓鱼', '老虎机', '百家乐', '赛马'];
|
|
|
|
|
|
if (gamePrefixes.some(p => name.startsWith(p))) return true;
|
|
|
|
|
|
// 含空格 → 一定不是用户名(用户名不允许含空格)
|
|
|
|
|
|
if (name.includes(' ')) return true;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 用户名(单击切换发言对象,双击查看资料;系统用户或游戏标签仅显示文本)
|
2026-02-26 21:57:54 +08:00
|
|
|
|
const clickableUser = (uName, color) => {
|
2026-03-26 09:45:03 +08:00
|
|
|
|
if (uName === 'AI小班长') {
|
2026-03-26 11:15:11 +08:00
|
|
|
|
return `<span class="msg-user" data-u="${uName}" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="openUserCard('${uName}')">${uName}</span>`;
|
2026-03-26 09:45:03 +08:00
|
|
|
|
}
|
2026-03-16 15:43:27 +08:00
|
|
|
|
if (systemUsers.includes(uName) || isGameLabel(uName)) {
|
2026-02-26 21:57:54 +08:00
|
|
|
|
return `<span class="msg-user" style="color: ${color};">${uName}</span>`;
|
|
|
|
|
|
}
|
2026-03-18 20:51:57 +08:00
|
|
|
|
return `<span class="msg-user" data-u="${uName}" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="openUserCard('${uName}')">${uName}</span>`;
|
2026-02-26 21:57:54 +08:00
|
|
|
|
};
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-03-26 11:15:11 +08:00
|
|
|
|
// 普通用户(包括 AI小班长)用数据库头像,播报类用特殊喇叭图标
|
2026-02-26 21:10:34 +08:00
|
|
|
|
const senderInfo = onlineUsers[msg.from_user];
|
2026-04-02 17:01:13 +08:00
|
|
|
|
const senderHead = ((senderInfo && senderInfo.headface) || '1.gif');
|
2026-03-12 15:26:54 +08:00
|
|
|
|
let headImgSrc = senderHead.startsWith('storage/') ? '/' + senderHead : `/images/headface/${senderHead}`;
|
2026-03-26 11:15:11 +08:00
|
|
|
|
if (msg.from_user.endsWith('播报') || msg.from_user === '星海小博士' || msg.from_user === '系统传音' || msg.from_user === '系统公告') {
|
2026-02-27 13:48:18 +08:00
|
|
|
|
headImgSrc = '/images/bugle.png';
|
2026-02-27 10:57:46 +08:00
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
const headImg =
|
2026-02-27 11:05:42 +08:00
|
|
|
|
`<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'">`;
|
2026-04-12 14:04:18 +08:00
|
|
|
|
const messageBodyHtml = buildChatMessageContent(msg, fontColor);
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
let html = '';
|
|
|
|
|
|
|
2026-02-28 23:44:38 +08:00
|
|
|
|
// 第一个判断分支:如果是纯 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}`;
|
|
|
|
|
|
}
|
2026-04-12 14:32:44 +08:00
|
|
|
|
// 会员专属进退场播报:更明亮喜庆的卡片化样式,由外层额外触发豪华横幅。
|
2026-04-11 15:44:30 +08:00
|
|
|
|
else if (msg.action === 'vip_presence') {
|
2026-04-12 14:32:44 +08:00
|
|
|
|
const accent = msg.presence_color || '#f59e0b';
|
|
|
|
|
|
// 优化背景为明亮的白色渐变,带上会员色调的淡影,显得吉利大气
|
2026-04-11 15:44:30 +08:00
|
|
|
|
div.style.cssText =
|
2026-04-12 14:32:44 +08:00
|
|
|
|
`background: linear-gradient(135deg, #ffffff, ${accent}08); border: 2px solid ${accent}44; border-radius: 16px; padding: 12px 16px; margin: 8px 0; box-shadow: 0 4px 15px ${accent}15; position: relative; overflow: hidden;`;
|
|
|
|
|
|
|
2026-04-11 15:44:30 +08:00
|
|
|
|
const icon = escapeHtml(msg.presence_icon || '👑');
|
|
|
|
|
|
const levelName = escapeHtml(msg.presence_level_name || '尊贵会员');
|
2026-04-12 23:25:38 +08:00
|
|
|
|
const typeLabel = msg.presence_type === 'leave'
|
|
|
|
|
|
? '华丽离场'
|
|
|
|
|
|
: (msg.presence_type === 'purchase' ? '荣耀开通' : '荣耀入场');
|
2026-04-11 15:44:30 +08:00
|
|
|
|
const safeText = escapePresenceText(msg.presence_text || '');
|
|
|
|
|
|
|
|
|
|
|
|
html = `
|
2026-04-12 14:32:44 +08:00
|
|
|
|
<div style="display:flex;align-items:center;gap:12px;">
|
|
|
|
|
|
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
|
2026-04-11 15:44:30 +08:00
|
|
|
|
<div style="min-width:0;flex:1;">
|
|
|
|
|
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
2026-04-12 14:32:44 +08:00
|
|
|
|
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
|
|
|
|
|
|
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
|
2026-04-11 15:44:30 +08:00
|
|
|
|
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
|
|
|
|
|
|
</div>
|
2026-04-12 14:32:44 +08:00
|
|
|
|
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
|
2026-04-11 15:44:30 +08:00
|
|
|
|
</div>
|
2026-04-12 14:32:44 +08:00
|
|
|
|
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
|
2026-04-11 15:44:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
timeStrOverride = true;
|
|
|
|
|
|
}
|
2026-03-17 21:24:31 +08:00
|
|
|
|
// 贾妖语 —— 蓝色左边框渐变样式,比 系统公告 低调
|
|
|
|
|
|
else if (msg.action === '欢迎') {
|
|
|
|
|
|
div.style.cssText =
|
|
|
|
|
|
'background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);';
|
|
|
|
|
|
|
|
|
|
|
|
let parsedContent = msg.content;
|
|
|
|
|
|
parsedContent = parsedContent.replace(/【([^】]+)】/g, function(match, uName) {
|
|
|
|
|
|
return '【' + clickableUser(uName, '#1d4ed8') + '】';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-17 21:26:57 +08:00
|
|
|
|
html =
|
|
|
|
|
|
`<div style="color: #1e40af;">💬 ${parsedContent} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
|
2026-03-17 21:24:31 +08:00
|
|
|
|
timeStrOverride = true;
|
|
|
|
|
|
}
|
2026-02-28 23:44:38 +08:00
|
|
|
|
// 接下来再判断各类发话人
|
|
|
|
|
|
else if (systemUsers.includes(msg.from_user)) {
|
2026-02-27 13:44:24 +08:00
|
|
|
|
if (msg.from_user === '系统公告') {
|
|
|
|
|
|
// 管理员公告:大字醒目红框样式
|
2026-02-26 22:34:00 +08:00
|
|
|
|
div.style.cssText =
|
2026-04-21 16:43:39 +08:00
|
|
|
|
'background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);';
|
2026-02-28 23:44:38 +08:00
|
|
|
|
|
|
|
|
|
|
let parsedContent = msg.content;
|
|
|
|
|
|
parsedContent = parsedContent.replace(/【([^】]+)】/g, function(match, uName) {
|
|
|
|
|
|
return '【' + clickableUser(uName, '#dc2626') + '】';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-27 01:09:26 +08:00
|
|
|
|
html =
|
2026-04-21 16:43:39 +08:00
|
|
|
|
`<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
|
2026-02-27 11:09:36 +08:00
|
|
|
|
timeStrOverride = true;
|
2026-02-27 13:44:24 +08:00
|
|
|
|
} else if (msg.from_user === '系统传音') {
|
2026-03-16 15:43:27 +08:00
|
|
|
|
// 自动升级播报 / 赠礼通知 / 彩票购买广播:金色左边框,轻量提示样式,不喧宾夺主
|
|
|
|
|
|
// 解析内容中 【用户名】 片段,使其支持单击(切换发言对象)和双击(查看名片)
|
2026-02-27 13:44:24 +08:00
|
|
|
|
div.style.cssText =
|
|
|
|
|
|
'background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;';
|
2026-03-16 15:43:27 +08:00
|
|
|
|
let sysTranContent = msg.content;
|
|
|
|
|
|
sysTranContent = sysTranContent.replace(/【([^】]+)】/g, function(match, uName) {
|
|
|
|
|
|
return '【' + clickableUser(uName, '#000099') + '】';
|
|
|
|
|
|
});
|
2026-02-27 13:44:24 +08:00
|
|
|
|
html =
|
2026-03-16 15:43:27 +08:00
|
|
|
|
`<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
|
2026-02-27 12:51:29 +08:00
|
|
|
|
} else if (msg.from_user === '系统' && msg.to_user && msg.to_user !== '大家') {
|
|
|
|
|
|
// 系统私人通知(自动存点等):无头像,绿色左边框简洁条形样式
|
|
|
|
|
|
div.style.cssText =
|
2026-02-27 16:33:40 +08:00
|
|
|
|
'background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;';
|
2026-02-27 12:51:29 +08:00
|
|
|
|
html =
|
|
|
|
|
|
`<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
|
2026-02-27 01:09:26 +08:00
|
|
|
|
} else {
|
2026-02-27 10:54:41 +08:00
|
|
|
|
// 其他系统用户(钓鱼播报、送花播报、AI小班长等):普通样式
|
2026-02-27 01:01:56 +08:00
|
|
|
|
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;">`;
|
|
|
|
|
|
}
|
2026-02-28 23:44:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 让带有【用户名】的系统通知变成可点击和双击的蓝色用户标
|
|
|
|
|
|
let parsedContent = msg.content;
|
|
|
|
|
|
// 利用正则匹配【用户名】结构,捕获组 $1 即是里面真正的用户名
|
|
|
|
|
|
parsedContent = parsedContent.replace(/【([^】]+)】/g, function(match, uName) {
|
|
|
|
|
|
return '【' + clickableUser(uName, '#000099') + '】';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-27 01:01:56 +08:00
|
|
|
|
html =
|
2026-03-26 09:45:03 +08:00
|
|
|
|
`${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor)}:</span><span class="msg-content" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
|
2026-02-26 22:34:00 +08:00
|
|
|
|
}
|
2026-02-26 21:57:54 +08:00
|
|
|
|
} else if (msg.is_secret) {
|
2026-02-27 12:53:30 +08:00
|
|
|
|
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 {
|
2026-02-27 12:58:31 +08:00
|
|
|
|
// 普通悄悄话样式(原版:紫色斜体,使用自然语序动作)
|
|
|
|
|
|
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}悄悄说:`;
|
2026-04-11 22:48:15 +08:00
|
|
|
|
html =
|
2026-04-12 14:04:18 +08:00
|
|
|
|
`${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
|
2026-02-27 12:53:30 +08:00
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
} else if (msg.to_user && msg.to_user !== '大家') {
|
2026-02-27 12:58:31 +08:00
|
|
|
|
// 对特定对象说话
|
|
|
|
|
|
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}说:`;
|
2026-04-12 14:04:18 +08:00
|
|
|
|
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
} else {
|
2026-02-27 12:58:31 +08:00
|
|
|
|
// 对大家说话
|
|
|
|
|
|
const fromHtml = clickableUser(msg.from_user, '#000099');
|
|
|
|
|
|
const verbStr = msg.action ?
|
|
|
|
|
|
buildActionStr(msg.action, fromHtml, '大家') :
|
|
|
|
|
|
`${fromHtml}对大家说:`;
|
2026-04-12 14:04:18 +08:00
|
|
|
|
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 11:09:36 +08:00
|
|
|
|
if (!timeStrOverride) {
|
|
|
|
|
|
html += ` <span class="msg-time">(${timeStr})</span>`;
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
div.innerHTML = html;
|
|
|
|
|
|
|
2026-04-14 22:31:11 +08:00
|
|
|
|
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
|
|
|
|
|
|
if (shouldHideByBlock) {
|
|
|
|
|
|
div.dataset.blockHidden = '1';
|
|
|
|
|
|
div.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 23:44:38 +08:00
|
|
|
|
// 后端下发的带有 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// 路由规则(复刻原版):
|
|
|
|
|
|
// 公众窗口(say1):别人的公聊消息
|
|
|
|
|
|
// 包厢窗口(say2):自己发的消息 + 悄悄话 + 对自己说的消息
|
|
|
|
|
|
const isRelatedToMe = isMe ||
|
|
|
|
|
|
msg.is_secret ||
|
|
|
|
|
|
msg.to_user === window.chatContext.username;
|
|
|
|
|
|
|
2026-02-28 11:56:42 +08:00
|
|
|
|
// 自动存点通知:标记 data-autosave 属性,每次渲染时先删除旧的,实现"滚动替换"效果
|
|
|
|
|
|
const isAutoSave = (msg.from_user === '系统' || msg.from_user === '') &&
|
|
|
|
|
|
msg.content && msg.content.includes('自动存点');
|
|
|
|
|
|
if (isAutoSave) {
|
|
|
|
|
|
div.dataset.autosave = '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
if (isRelatedToMe) {
|
2026-02-28 11:56:42 +08:00
|
|
|
|
// 删除旧的自动存点通知,保持包厢窗口整洁
|
|
|
|
|
|
if (isAutoSave) {
|
|
|
|
|
|
container2.querySelectorAll('[data-autosave="1"]').forEach(el => el.remove());
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
container2.appendChild(div);
|
2026-04-11 15:54:25 +08:00
|
|
|
|
if (autoScroll) {
|
|
|
|
|
|
container2.scrollTop = container2.scrollHeight;
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
container.appendChild(div);
|
|
|
|
|
|
scrollToBottom();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 15:54:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 将消息追加函数暴露到全局,供页面首次加载时回填历史消息使用。
|
|
|
|
|
|
*/
|
|
|
|
|
|
window.appendMessage = appendMessage;
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// ── WebSocket 初始化 ─────────────────────────────
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
|
if (typeof window.initChat === 'function') {
|
|
|
|
|
|
window.initChat(window.chatContext.roomId);
|
|
|
|
|
|
}
|
2026-03-18 20:51:57 +08:00
|
|
|
|
|
|
|
|
|
|
// 手机端:在公屏(say1)和包厢(say2)容器上注册 touchend 委托
|
|
|
|
|
|
// 检测 300ms 内两次触摸同一 [data-u] 用户名 span,触发 openUserCard
|
|
|
|
|
|
let _msgTapTarget = null;
|
|
|
|
|
|
let _msgTapTime = 0;
|
|
|
|
|
|
['say1', 'say2'].forEach(id => {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (!el) { return; }
|
|
|
|
|
|
el.addEventListener('touchend', (e) => {
|
|
|
|
|
|
const span = e.target.closest('[data-u]');
|
|
|
|
|
|
if (!span) { return; }
|
|
|
|
|
|
const uName = span.dataset.u;
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (uName === _msgTapTarget && now - _msgTapTime < 300) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
openUserCard(uName);
|
|
|
|
|
|
_msgTapTarget = null;
|
|
|
|
|
|
_msgTapTime = 0;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_msgTapTarget = uName;
|
|
|
|
|
|
_msgTapTime = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { passive: false });
|
|
|
|
|
|
});
|
2026-02-26 21:10:34 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── WebSocket 事件监听 ────────────────────────────
|
|
|
|
|
|
window.addEventListener('chat:here', (e) => {
|
|
|
|
|
|
const users = e.detail;
|
|
|
|
|
|
onlineUsers = {};
|
|
|
|
|
|
users.forEach(u => {
|
|
|
|
|
|
onlineUsers[u.username] = u;
|
|
|
|
|
|
});
|
2026-03-26 11:15:11 +08:00
|
|
|
|
|
|
|
|
|
|
// 初始加载时,如果全局且开启,注入 AI
|
|
|
|
|
|
if (window.chatContext.chatBotEnabled && window.chatContext.botUser) {
|
|
|
|
|
|
onlineUsers['AI小班长'] = window.chatContext.botUser;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
renderUserList();
|
2026-02-27 14:34:04 +08:00
|
|
|
|
|
2026-04-02 16:07:40 +08:00
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-26 11:15:11 +08:00
|
|
|
|
// 监听机器人动态开关
|
|
|
|
|
|
window.addEventListener('chat:bot-toggled', (e) => {
|
|
|
|
|
|
const detail = e.detail;
|
|
|
|
|
|
window.chatContext.chatBotEnabled = detail.isOnline;
|
|
|
|
|
|
|
|
|
|
|
|
if (detail.isOnline && detail.user && detail.user.username) {
|
|
|
|
|
|
onlineUsers[detail.user.username] = detail.user;
|
|
|
|
|
|
window.chatContext.botUser = detail.user;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete onlineUsers['AI小班长'];
|
|
|
|
|
|
window.chatContext.botUser = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
renderUserList();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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);
|
2026-04-11 15:44:30 +08:00
|
|
|
|
|
|
|
|
|
|
if (msg.action === 'vip_presence') {
|
|
|
|
|
|
showVipPresenceBanner(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 12:15:18 +08:00
|
|
|
|
// 若消息携带 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('chat:kicked', (e) => {
|
|
|
|
|
|
if (e.detail.username === window.chatContext.username) {
|
2026-03-01 01:33:41 +08:00
|
|
|
|
window.chatDialog.alert('您已被管理员踢出房间!' + (e.detail.reason ? ' 原因:' + e.detail.reason : ''), '系统通知',
|
|
|
|
|
|
'#cc4444');
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-02-26 23:15:27 +08:00
|
|
|
|
const isMe = d.username === window.chatContext.username;
|
|
|
|
|
|
|
|
|
|
|
|
// 禁言通知:自己被禁言显示在包厢(say2),其他人显示在公聊(say1)
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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>`;
|
2026-02-26 23:15:27 +08:00
|
|
|
|
const targetContainer = isMe ? document.getElementById('say2') : container;
|
|
|
|
|
|
if (targetContainer) {
|
|
|
|
|
|
targetContainer.appendChild(div);
|
|
|
|
|
|
targetContainer.scrollTop = targetContainer.scrollHeight;
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果是自己被禁言,设置本地禁言计时
|
2026-02-26 23:15:27 +08:00
|
|
|
|
if (isMe && d.mute_time > 0) {
|
2026-02-26 21:10:34 +08:00
|
|
|
|
isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
|
|
|
|
|
|
const contentInput = document.getElementById('content');
|
2026-02-26 23:12:55 +08:00
|
|
|
|
const operatorName = d.operator || '管理员';
|
2026-02-26 21:10:34 +08:00
|
|
|
|
if (contentInput) {
|
2026-02-26 23:12:55 +08:00
|
|
|
|
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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>';
|
2026-02-26 23:15:27 +08:00
|
|
|
|
// 解禁提示也显示在包厢窗口
|
|
|
|
|
|
const say2 = document.getElementById('say2');
|
|
|
|
|
|
if (say2) {
|
|
|
|
|
|
say2.appendChild(unmuteDiv);
|
|
|
|
|
|
say2.scrollTop = say2.scrollHeight;
|
|
|
|
|
|
}
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}, d.mute_time * 60 * 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('chat:title-updated', (e) => {
|
|
|
|
|
|
document.getElementById('room-title-display').innerText = e.detail.title;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-21 17:14:12 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 收到站长的全员刷新通知后,先弹出提示,再延迟刷新页面。
|
|
|
|
|
|
*/
|
|
|
|
|
|
window.addEventListener('chat:browser-refresh-requested', (e) => {
|
|
|
|
|
|
const detail = e.detail || {};
|
|
|
|
|
|
const operatorName = escapeHtml(String(detail.operator || '站长'));
|
|
|
|
|
|
const reasonText = escapeHtml(String(detail.reason || '页面功能已更新,请重新载入。'));
|
|
|
|
|
|
|
|
|
|
|
|
window.chatToast?.show({
|
|
|
|
|
|
title: '页面即将刷新',
|
|
|
|
|
|
message: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
|
|
|
|
|
|
icon: '♻️',
|
|
|
|
|
|
color: '#0f766e',
|
|
|
|
|
|
duration: 2200,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
window.location.reload();
|
|
|
|
|
|
}, 900);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 任命/撤职后,目标用户收到定向刷新通知,自动同步页面上的权限按钮。
|
|
|
|
|
|
*/
|
|
|
|
|
|
window.addEventListener('chat:user-browser-refresh-requested', (e) => {
|
|
|
|
|
|
const detail = e.detail || {};
|
|
|
|
|
|
const operatorName = escapeHtml(String(detail.operator || '管理员'));
|
|
|
|
|
|
const reasonText = escapeHtml(String(detail.reason || '你的权限状态已发生变化,页面即将刷新。'));
|
|
|
|
|
|
|
|
|
|
|
|
window.chatToast?.show({
|
|
|
|
|
|
title: '权限同步中',
|
|
|
|
|
|
message: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
|
|
|
|
|
|
icon: '🔄',
|
|
|
|
|
|
color: '#7c3aed',
|
|
|
|
|
|
duration: 2600,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
window.location.reload();
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-26 23:43:09 +08:00
|
|
|
|
// ── 管理员全员清屏事件(等待 Echo 就绪后监听) ───────
|
|
|
|
|
|
function setupScreenClearedListener() {
|
|
|
|
|
|
if (!window.Echo || !window.chatContext) {
|
|
|
|
|
|
// Echo 或 chatContext 还没就绪,延迟重试
|
|
|
|
|
|
setTimeout(setupScreenClearedListener, 500);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-26 23:20:02 +08:00
|
|
|
|
window.Echo.join(`room.${window.chatContext.roomId}`)
|
|
|
|
|
|
.listen('ScreenCleared', (e) => {
|
2026-02-26 23:43:09 +08:00
|
|
|
|
console.log('收到全员清屏事件:', e);
|
2026-02-26 23:20:02 +08:00
|
|
|
|
const operator = e.operator;
|
2026-04-19 14:43:02 +08:00
|
|
|
|
const safeOperator = escapeHtml(String(operator || ''));
|
2026-02-26 23:20:02 +08:00
|
|
|
|
|
2026-02-26 23:45:24 +08:00
|
|
|
|
// 清除公聊窗口所有消息
|
|
|
|
|
|
const say1 = document.getElementById('chat-messages-container');
|
2026-02-26 23:20:02 +08:00
|
|
|
|
if (say1) say1.innerHTML = '';
|
|
|
|
|
|
|
2026-02-26 23:45:24 +08:00
|
|
|
|
// 清除包厢窗口中非悄悄话的消息
|
|
|
|
|
|
const say2 = document.getElementById('chat-messages-container2');
|
2026-02-26 23:20:02 +08:00
|
|
|
|
if (say2) {
|
|
|
|
|
|
const items = say2.querySelectorAll('.msg-line');
|
|
|
|
|
|
items.forEach(item => {
|
|
|
|
|
|
// 保留悄悄话消息(含 msg-secret 类)
|
|
|
|
|
|
if (!item.querySelector('.msg-secret')) {
|
|
|
|
|
|
item.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-26 23:05:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 23:20:02 +08:00
|
|
|
|
// 显示清屏提示
|
|
|
|
|
|
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 =
|
2026-04-19 14:43:02 +08:00
|
|
|
|
`<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
2026-02-26 23:20:02 +08:00
|
|
|
|
if (say1) {
|
|
|
|
|
|
say1.appendChild(sysDiv);
|
|
|
|
|
|
say1.scrollTop = say1.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-26 23:43:09 +08:00
|
|
|
|
console.log('ScreenCleared 监听器已注册');
|
2026-02-26 23:20:02 +08:00
|
|
|
|
}
|
2026-02-26 23:43:09 +08:00
|
|
|
|
// DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', setupScreenClearedListener);
|
2026-02-26 23:05:56 +08:00
|
|
|
|
|
2026-04-21 17:14:12 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 注册房间级“刷新全员”监听。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 放在 Blade 脚本内,避免前端资源未重新构建时收不到刷新广播。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function setupRoomBrowserRefreshListener() {
|
|
|
|
|
|
if (!window.Echo || !window.chatContext) {
|
|
|
|
|
|
setTimeout(setupRoomBrowserRefreshListener, 500);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.Echo.join(`room.${window.chatContext.roomId}`)
|
|
|
|
|
|
.listen('BrowserRefreshRequested', (e) => {
|
|
|
|
|
|
console.log('收到全员刷新事件:', e);
|
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
|
new CustomEvent('chat:browser-refresh-requested', {
|
|
|
|
|
|
detail: e
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
console.log('BrowserRefreshRequested 监听器已注册');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', setupRoomBrowserRefreshListener);
|
|
|
|
|
|
|
2026-02-28 23:44:38 +08:00
|
|
|
|
// ── 开发日志发布通知(仅 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');
|
2026-04-19 14:43:02 +08:00
|
|
|
|
const safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ''));
|
|
|
|
|
|
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ''));
|
|
|
|
|
|
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, '{{ route('changelog.index') }}'));
|
2026-02-28 23:44:38 +08:00
|
|
|
|
|
|
|
|
|
|
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;">
|
2026-04-19 14:43:02 +08:00
|
|
|
|
📋 【版本更新】v${safeVersion} · ${safeTitle}
|
|
|
|
|
|
<a href="${safeUrl}" target="_blank" rel="noopener"
|
2026-02-28 23:44:38 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-03-12 08:35:21 +08:00
|
|
|
|
// ── 五子棋 PvP 邀请通知(聊天室内显示「接受挑战」按钮)───────
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 监听 .gomoku.invite 事件,在聊天窗口追加邀请消息行。
|
|
|
|
|
|
* 发起者收到的邀请(自己发出的)不显示接受按钮。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function setupGomokuInviteListener() {
|
|
|
|
|
|
if (!window.Echo || !window.chatContext) {
|
|
|
|
|
|
setTimeout(setupGomokuInviteListener, 500);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.Echo.join(`room.${window.chatContext.roomId}`)
|
|
|
|
|
|
.listen('.gomoku.invite', (e) => {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
|
|
|
|
|
|
now.getMinutes().toString().padStart(2, '0') + ':' +
|
|
|
|
|
|
now.getSeconds().toString().padStart(2, '0');
|
|
|
|
|
|
|
|
|
|
|
|
const isSelf = (e.inviter_name === window.chatContext.username);
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'msg-line';
|
|
|
|
|
|
div.style.cssText =
|
|
|
|
|
|
'background:linear-gradient(135deg,#e8eef8,#f0f4fc); ' +
|
|
|
|
|
|
'border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;';
|
|
|
|
|
|
|
|
|
|
|
|
const acceptBtn = isSelf ?
|
|
|
|
|
|
// 自己的邀请:只显示打开面板按钮,方便被关掉后重新进入
|
|
|
|
|
|
`<button onclick="document.querySelector('[x-data=\"gomokuPanel()\"]').__x.$data.open()"
|
|
|
|
|
|
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
|
|
|
|
|
|
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
|
|
|
|
|
|
cursor:pointer; font-family:inherit; transition:all .15s;"
|
|
|
|
|
|
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
|
|
|
|
|
|
⤴️ 打开面板
|
|
|
|
|
|
</button>` :
|
|
|
|
|
|
// 别人的邀请:显示接受挑战按钮
|
|
|
|
|
|
`<button onclick="acceptGomokuInvite(${e.game_id})" id="gomoku-accept-${e.game_id}"
|
|
|
|
|
|
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
|
|
|
|
|
|
border-radius:12px; background:#336699; color:#fff; font-size:12px;
|
|
|
|
|
|
cursor:pointer; font-family:inherit; transition:all .15s;"
|
|
|
|
|
|
onmouseover="this.style.opacity='.8'" onmouseout="this.style.opacity='1'">
|
|
|
|
|
|
⚔️ 接受挑战
|
|
|
|
|
|
</button>`;
|
|
|
|
|
|
|
|
|
|
|
|
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
|
|
|
|
|
|
♟️ 【五子棋】<b>${e.inviter_name}</b> 发起了随机对战!${isSelf ? '(等待中)' : ''}
|
|
|
|
|
|
</span>${acceptBtn}
|
|
|
|
|
|
<span class="msg-time">(${timeStr})</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
// 追加到公聊窗口
|
|
|
|
|
|
const say1 = document.getElementById('chat-messages-container');
|
|
|
|
|
|
if (say1) {
|
|
|
|
|
|
say1.appendChild(div);
|
|
|
|
|
|
say1.scrollTop = say1.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 60 秒后移除接受按钮(邀请超时)
|
|
|
|
|
|
if (!isSelf) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const btn = document.getElementById(`gomoku-accept-${e.game_id}`);
|
|
|
|
|
|
if (btn) {
|
|
|
|
|
|
btn.textContent = '已超时';
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.style.opacity = '.5';
|
|
|
|
|
|
btn.style.cursor = 'not-allowed';
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 60000);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.listen('.gomoku.finished', (e) => {
|
|
|
|
|
|
// 对局结束:在公聊展示战报(仅 PvP 有战报意义)
|
|
|
|
|
|
if (e.mode !== 'pvp') return;
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
|
|
|
|
|
|
now.getMinutes().toString().padStart(2, '0') + ':' +
|
|
|
|
|
|
now.getSeconds().toString().padStart(2, '0');
|
|
|
|
|
|
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'msg-line';
|
|
|
|
|
|
div.style.cssText =
|
|
|
|
|
|
'background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;';
|
|
|
|
|
|
|
|
|
|
|
|
const reason = {
|
|
|
|
|
|
win: '获胜',
|
|
|
|
|
|
draw: '平局',
|
|
|
|
|
|
resign: '认输',
|
|
|
|
|
|
timeout: '超时'
|
|
|
|
|
|
} [e.reason] || '结束';
|
|
|
|
|
|
let text = '';
|
|
|
|
|
|
if (e.winner === 0) {
|
|
|
|
|
|
text = `♟️ 五子棋对局以<b>平局</b>结束!`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
text =
|
|
|
|
|
|
`♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>(${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
div.innerHTML =
|
|
|
|
|
|
`<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
|
|
|
|
|
|
const say1 = document.getElementById('chat-messages-container');
|
|
|
|
|
|
if (say1) {
|
|
|
|
|
|
say1.appendChild(div);
|
|
|
|
|
|
say1.scrollTop = say1.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
console.log('[五子棋] 邀请监听器已注册');
|
|
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', setupGomokuInviteListener);
|
|
|
|
|
|
|
2026-04-12 16:48:58 +08:00
|
|
|
|
// ── 全屏特效事件监听(管理员菜单 / 会员进出场通用)─────────
|
2026-02-27 14:14:35 +08:00
|
|
|
|
window.addEventListener('chat:effect', (e) => {
|
|
|
|
|
|
const type = e.detail?.type;
|
2026-02-27 16:19:21 +08:00
|
|
|
|
const target = e.detail?.target_username; // null = 全员,otherwise 指定昵称
|
2026-04-21 17:14:12 +08:00
|
|
|
|
const operator = e.detail?.operator; // 定向赠送时,购买者自己也应能看到特效
|
2026-02-27 16:19:21 +08:00
|
|
|
|
const myName = window.chatContext?.username;
|
|
|
|
|
|
|
2026-04-21 17:14:12 +08:00
|
|
|
|
// null 表示全员;若有指定接收者,则购买者本人和指定用户都播放
|
2026-02-27 14:14:35 +08:00
|
|
|
|
if (type && typeof EffectManager !== 'undefined') {
|
2026-04-21 17:14:12 +08:00
|
|
|
|
if (!target || target === myName || operator === myName) {
|
2026-02-27 16:19:21 +08:00
|
|
|
|
EffectManager.play(type);
|
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 管理员点击特效按钮,向后端 POST /command/effect
|
|
|
|
|
|
*
|
2026-04-12 16:48:58 +08:00
|
|
|
|
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
2026-02-27 14:14:35 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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 => {
|
2026-03-01 01:32:20 +08:00
|
|
|
|
if (data.status !== 'success') window.chatDialog.alert(data.message, '操作失败', '#cc4444');
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}).catch(err => console.error('特效触发失败:', err));
|
|
|
|
|
|
}
|
2026-04-12 16:54:25 +08:00
|
|
|
|
window.toggleAdminMenu = toggleAdminMenu;
|
2026-04-14 22:25:16 +08:00
|
|
|
|
window.toggleBlockMenu = toggleBlockMenu;
|
2026-04-12 16:54:25 +08:00
|
|
|
|
window.runAdminAction = runAdminAction;
|
2026-04-12 16:24:48 +08:00
|
|
|
|
window.selectEffect = selectEffect;
|
2026-02-27 14:14:35 +08:00
|
|
|
|
window.triggerEffect = triggerEffect;
|
2026-04-14 22:25:16 +08:00
|
|
|
|
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
2026-02-27 14:46:49 +08:00
|
|
|
|
// ── 字号设置(持久化到 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);
|
|
|
|
|
|
}
|
2026-04-14 22:48:29 +08:00
|
|
|
|
|
|
|
|
|
|
const storedBlockedSystemSenders = loadBlockedSystemSenders();
|
|
|
|
|
|
const mutedFromLocal = localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === '1';
|
|
|
|
|
|
const hasServerPreferences = initialChatPreferences.blocked_system_senders.length > 0 || initialChatPreferences.sound_muted;
|
|
|
|
|
|
const shouldMigrateLocalPreferences = !hasServerPreferences
|
|
|
|
|
|
&& (storedBlockedSystemSenders.length > 0 || mutedFromLocal);
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldMigrateLocalPreferences) {
|
|
|
|
|
|
blockedSystemSenders = new Set(storedBlockedSystemSenders);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复禁音复选框状态;默认一律为未禁音。
|
|
|
|
|
|
const muted = shouldMigrateLocalPreferences ? mutedFromLocal : initialChatPreferences.sound_muted;
|
2026-03-01 13:19:24 +08:00
|
|
|
|
const muteChk = document.getElementById('sound_muted');
|
|
|
|
|
|
if (muteChk) muteChk.checked = muted;
|
2026-04-14 22:25:16 +08:00
|
|
|
|
syncBlockedSystemSenderCheckboxes();
|
2026-04-14 22:48:29 +08:00
|
|
|
|
|
|
|
|
|
|
if (shouldMigrateLocalPreferences) {
|
|
|
|
|
|
void saveChatPreferences();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
persistChatPreferencesToLocal();
|
|
|
|
|
|
}
|
2026-02-27 14:46:49 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-01 13:19:24 +08:00
|
|
|
|
// ── 特效禁音开关 ─────────────────────────────────────────────────
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换特效音效的静音状态,持久化到 localStorage。
|
|
|
|
|
|
* 开启禁音后立即停止当前正在播放的音效。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {boolean} muted true = 禁音,false = 开启声音
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toggleSoundMute(muted) {
|
2026-04-14 22:48:29 +08:00
|
|
|
|
localStorage.setItem(CHAT_SOUND_MUTED_STORAGE_KEY, muted ? '1' : '0');
|
2026-03-01 13:19:24 +08:00
|
|
|
|
if (muted && typeof EffectSounds !== 'undefined') {
|
|
|
|
|
|
EffectSounds.stop(); // 立即停止当前音效
|
|
|
|
|
|
}
|
2026-04-14 22:48:29 +08:00
|
|
|
|
|
|
|
|
|
|
void saveChatPreferences();
|
2026-03-01 13:19:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
window.toggleSoundMute = toggleSoundMute;
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 14:53:45 +08:00
|
|
|
|
// ── 发送消息(Enter 发送,防 IME 输入法重复触发)────────
|
|
|
|
|
|
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
|
|
|
|
|
|
let _imeComposing = false;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
let _isSending = false; // 发送中防重入标记
|
|
|
|
|
|
let _sendStartedAt = 0; // 记录发送开始时间,用于页面恢复后释放异常锁
|
2026-02-27 14:53:45 +08:00
|
|
|
|
const _contentInput = document.getElementById('content');
|
2026-04-19 12:14:10 +08:00
|
|
|
|
const CHAT_DRAFT_STORAGE_KEY = `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`;
|
2026-02-27 14:53:45 +08:00
|
|
|
|
|
2026-04-12 14:04:18 +08:00
|
|
|
|
/**
|
2026-04-19 12:14:10 +08:00
|
|
|
|
* 将当前输入框内容保存到会话级草稿缓存。
|
2026-04-12 14:04:18 +08:00
|
|
|
|
*/
|
2026-04-19 12:14:10 +08:00
|
|
|
|
function persistChatDraft(value = null) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const draft = value ?? _contentInput?.value ?? '';
|
|
|
|
|
|
if (draft === '') {
|
|
|
|
|
|
sessionStorage.removeItem(CHAT_DRAFT_STORAGE_KEY);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sessionStorage.setItem(CHAT_DRAFT_STORAGE_KEY, draft);
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// 会话存储不可用时静默降级,不影响聊天主流程
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从会话缓存中恢复聊天草稿。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function loadChatDraft() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return sessionStorage.getItem(CHAT_DRAFT_STORAGE_KEY) || '';
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将当前输入区状态整理为一份稳定快照,避免直接序列化整张表单。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {Object}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function collectChatComposerState() {
|
|
|
|
|
|
const contentInput = document.getElementById('content');
|
|
|
|
|
|
const submitBtn = document.getElementById('send-btn');
|
|
|
|
|
|
const imageInput = document.getElementById('chat_image');
|
|
|
|
|
|
const toUserSelect = document.getElementById('to_user');
|
|
|
|
|
|
const actionSelect = document.getElementById('action');
|
|
|
|
|
|
const fontColorInput = document.getElementById('font_color');
|
|
|
|
|
|
const secretCheckbox = document.getElementById('is_secret');
|
|
|
|
|
|
const contentRaw = contentInput?.value ?? '';
|
|
|
|
|
|
const selectedImage = imageInput?.files?.[0] ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
contentInput,
|
|
|
|
|
|
submitBtn,
|
|
|
|
|
|
imageInput,
|
|
|
|
|
|
contentRaw,
|
|
|
|
|
|
content: contentRaw.trim(),
|
|
|
|
|
|
selectedImage,
|
|
|
|
|
|
toUser: toUserSelect?.value || '大家',
|
|
|
|
|
|
action: actionSelect?.value || '',
|
|
|
|
|
|
fontColor: fontColorInput?.value || '',
|
|
|
|
|
|
isSecret: Boolean(secretCheckbox?.checked),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 基于当前聊天快照构造稳定的 multipart 请求体。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Object} composerState
|
|
|
|
|
|
* @returns {FormData}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildChatMessageFormData(composerState) {
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('content', composerState.contentRaw);
|
|
|
|
|
|
formData.append('to_user', composerState.toUser);
|
|
|
|
|
|
formData.append('action', composerState.action);
|
|
|
|
|
|
formData.append('font_color', composerState.fontColor);
|
|
|
|
|
|
|
|
|
|
|
|
if (composerState.isSecret) {
|
|
|
|
|
|
formData.append('is_secret', '1');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (composerState.selectedImage) {
|
|
|
|
|
|
formData.append('image', composerState.selectedImage);
|
2026-04-12 14:04:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 12:14:10 +08:00
|
|
|
|
return formData;
|
2026-04-12 14:04:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理聊天图片选择后的前端状态展示。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleChatImageSelected(input) {
|
|
|
|
|
|
const file = input?.files?.[0] ?? null;
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 用户选择图片后,立即触发自动发送
|
|
|
|
|
|
sendMessage(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理当前选中的聊天图片。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function clearSelectedChatImage(resetInput = false) {
|
|
|
|
|
|
const imageInput = document.getElementById('chat_image');
|
|
|
|
|
|
|
|
|
|
|
|
if (resetInput && imageInput) {
|
|
|
|
|
|
imageInput.value = '';
|
|
|
|
|
|
}
|
2026-04-19 12:14:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 页面从后台恢复后,同步草稿、图片提示和发送锁状态。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function syncChatComposerAfterResume() {
|
|
|
|
|
|
if (!_contentInput) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const savedDraft = loadChatDraft();
|
|
|
|
|
|
if (_contentInput.value === '' && savedDraft !== '') {
|
|
|
|
|
|
_contentInput.value = savedDraft;
|
|
|
|
|
|
} else if (_contentInput.value !== '') {
|
|
|
|
|
|
persistChatDraft(_contentInput.value);
|
|
|
|
|
|
}
|
2026-04-12 14:04:18 +08:00
|
|
|
|
|
2026-04-19 12:14:10 +08:00
|
|
|
|
const imageInput = document.getElementById('chat_image');
|
|
|
|
|
|
if (!imageInput?.files?.length) {
|
|
|
|
|
|
clearSelectedChatImage();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_imeComposing = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (_isSending && Date.now() - _sendStartedAt > 15000) {
|
|
|
|
|
|
const submitBtn = document.getElementById('send-btn');
|
|
|
|
|
|
if (submitBtn) {
|
|
|
|
|
|
submitBtn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_isSending = false;
|
|
|
|
|
|
_sendStartedAt = 0;
|
|
|
|
|
|
}
|
2026-04-12 14:04:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 打开聊天图片大图预览层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function openChatImageLightbox(imageUrl, imageName = '聊天图片') {
|
|
|
|
|
|
const lightbox = document.getElementById('chat-image-lightbox');
|
|
|
|
|
|
const imageEl = document.getElementById('chat-image-lightbox-img');
|
|
|
|
|
|
const nameEl = document.getElementById('chat-image-lightbox-name');
|
|
|
|
|
|
|
|
|
|
|
|
if (!lightbox || !imageEl || !imageUrl) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
imageEl.src = imageUrl;
|
|
|
|
|
|
imageEl.alt = imageName;
|
|
|
|
|
|
|
|
|
|
|
|
if (nameEl) {
|
|
|
|
|
|
nameEl.textContent = imageName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lightbox.style.display = 'block';
|
|
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 关闭聊天图片大图预览层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function closeChatImageLightbox(event = null) {
|
|
|
|
|
|
// 如果是点击事件,且点击的目标不是背景或关闭按钮(比如点击了图片本身且没有阻止冒泡),则不关闭
|
|
|
|
|
|
// 已经在 HTML 中对 img 做了 stopPropagation,此处 event.target !== event.currentTarget 仍是安全的
|
|
|
|
|
|
if (event && event.target !== event.currentTarget) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const lightbox = document.getElementById('chat-image-lightbox');
|
|
|
|
|
|
if (!lightbox) return;
|
|
|
|
|
|
|
|
|
|
|
|
lightbox.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const imageEl = document.getElementById('chat-image-lightbox-img');
|
|
|
|
|
|
if (imageEl) {
|
|
|
|
|
|
imageEl.src = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.handleChatImageSelected = handleChatImageSelected;
|
|
|
|
|
|
window.openChatImageLightbox = openChatImageLightbox;
|
|
|
|
|
|
window.closeChatImageLightbox = closeChatImageLightbox;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
syncChatComposerAfterResume();
|
2026-04-12 14:04:18 +08:00
|
|
|
|
|
2026-04-19 12:14:10 +08:00
|
|
|
|
if (_contentInput) {
|
|
|
|
|
|
_contentInput.addEventListener('input', function() {
|
|
|
|
|
|
persistChatDraft(this.value);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 中文/日文等 IME 组词开始
|
|
|
|
|
|
_contentInput.addEventListener('compositionstart', () => {
|
|
|
|
|
|
_imeComposing = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
// 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉
|
|
|
|
|
|
_contentInput.addEventListener('compositionend', () => {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
_imeComposing = false;
|
|
|
|
|
|
}, 10);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
_contentInput.addEventListener('keydown', function(e) {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
// IME 正在组词时(如选候选汉字),不触发发送
|
|
|
|
|
|
if (_imeComposing) return;
|
|
|
|
|
|
sendMessage(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-27 14:53:45 +08:00
|
|
|
|
|
2026-04-19 12:14:10 +08:00
|
|
|
|
window.addEventListener('pageshow', syncChatComposerAfterResume);
|
|
|
|
|
|
document.addEventListener('visibilitychange', function() {
|
|
|
|
|
|
if (document.visibilityState === 'visible') {
|
|
|
|
|
|
syncChatComposerAfterResume();
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-19 12:14:10 +08:00
|
|
|
|
window.addEventListener('focus', function() {
|
|
|
|
|
|
setTimeout(syncChatComposerAfterResume, 0);
|
|
|
|
|
|
});
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-27 14:53:45 +08:00
|
|
|
|
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)
|
2026-02-26 21:10:34 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async function sendMessage(e) {
|
|
|
|
|
|
if (e) e.preventDefault();
|
2026-02-27 14:53:45 +08:00
|
|
|
|
if (_isSending) return; // 上一次还没结束,忽略
|
|
|
|
|
|
_isSending = true;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
_sendStartedAt = Date.now();
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 前端禁言检查
|
|
|
|
|
|
if (isMutedUntil > Date.now()) {
|
|
|
|
|
|
const remaining = Math.ceil((isMutedUntil - Date.now()) / 1000);
|
2026-02-26 23:12:55 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-27 17:45:18 +08:00
|
|
|
|
_isSending = false;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
_sendStartedAt = 0;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 12:14:10 +08:00
|
|
|
|
const composerState = collectChatComposerState();
|
|
|
|
|
|
const {
|
|
|
|
|
|
contentInput,
|
|
|
|
|
|
submitBtn,
|
|
|
|
|
|
content,
|
|
|
|
|
|
contentRaw,
|
|
|
|
|
|
selectedImage,
|
|
|
|
|
|
toUser,
|
|
|
|
|
|
} = composerState;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
2026-04-12 14:04:18 +08:00
|
|
|
|
if (!content && !selectedImage) {
|
2026-02-26 21:10:34 +08:00
|
|
|
|
contentInput.focus();
|
2026-02-27 17:45:18 +08:00
|
|
|
|
_isSending = false;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
_sendStartedAt = 0;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 11:15:11 +08:00
|
|
|
|
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
|
2026-04-12 14:04:18 +08:00
|
|
|
|
if (toUser === 'AI小班长' && content) {
|
2026-02-28 11:12:51 +08:00
|
|
|
|
sendToChatBot(content); // 异步调用,不阻塞全局发送
|
2026-02-26 21:30:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 19:29:43 +08:00
|
|
|
|
// ── 神秘箱子暗号拦截 ────────────────────────────────────
|
|
|
|
|
|
// 当用户输入内容符合暗号格式(4-8位大写字母/数字)时,主动尝试领取
|
|
|
|
|
|
// 不依赖轮询标志,服务端负责校验是否真有活跃箱子及暗号是否匹配
|
|
|
|
|
|
const passcodePattern = /^[A-Z0-9]{4,8}$/;
|
2026-04-12 14:04:18 +08:00
|
|
|
|
if (!selectedImage && passcodePattern.test(content.trim())) {
|
2026-03-03 19:29:43 +08:00
|
|
|
|
_isSending = false;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
_sendStartedAt = 0;
|
2026-03-03 19:29:43 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const claimRes = await fetch('/mystery-box/claim', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
passcode: content.trim()
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const claimData = await claimRes.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (claimData.ok) {
|
|
|
|
|
|
// ✅ 领取成功:清空输入框,不发送普通消息
|
|
|
|
|
|
contentInput.value = '';
|
2026-04-19 12:14:10 +08:00
|
|
|
|
persistChatDraft('');
|
2026-03-03 19:29:43 +08:00
|
|
|
|
contentInput.focus();
|
|
|
|
|
|
|
|
|
|
|
|
// 清除活跃箱子全局标志
|
|
|
|
|
|
window._mysteryBoxActive = false;
|
|
|
|
|
|
window._mysteryBoxPasscode = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 弹出开箱结果卡片
|
|
|
|
|
|
const isPositive = (claimData.reward ?? 1) >= 0;
|
|
|
|
|
|
window.chatDialog?.alert(
|
|
|
|
|
|
claimData.message || '开箱成功!',
|
|
|
|
|
|
isPositive ? '🎉 恭喜!' : '☠️ 中了陷阱!',
|
|
|
|
|
|
isPositive ? '#10b981' : '#ef4444',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新全局金币余额显示
|
|
|
|
|
|
if (window.__chatUser && claimData.balance !== undefined) {
|
|
|
|
|
|
window.__chatUser.jjb = claimData.balance;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ❌ 领取失败(暗号错误 / 无活跃箱子 / 已被领走)
|
|
|
|
|
|
// 静默回退到正常发送——不弹错误提示,让消息正常发出
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// 网络错误时同样静默回退正常发送
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
submitBtn.disabled = true;
|
2026-04-19 12:14:10 +08:00
|
|
|
|
const formData = buildChatMessageFormData({
|
|
|
|
|
|
...composerState,
|
|
|
|
|
|
contentRaw,
|
|
|
|
|
|
});
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
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 = '';
|
2026-04-19 12:14:10 +08:00
|
|
|
|
persistChatDraft('');
|
2026-04-12 14:04:18 +08:00
|
|
|
|
clearSelectedChatImage(true);
|
2026-02-26 21:10:34 +08:00
|
|
|
|
contentInput.focus();
|
|
|
|
|
|
} else {
|
2026-03-01 01:33:41 +08:00
|
|
|
|
window.chatDialog.alert('发送失败: ' + (data.message || JSON.stringify(data.errors)), '操作失败',
|
2026-03-01 01:54:19 +08:00
|
|
|
|
'#cc4444');
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-03-01 01:32:20 +08:00
|
|
|
|
window.chatDialog.alert('网络连接错误,消息发送失败!', '网络错误', '#cc4444');
|
2026-02-26 21:10:34 +08:00
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitBtn.disabled = false;
|
2026-02-27 14:53:45 +08:00
|
|
|
|
_isSending = false; // 释放发送锁,允许下次发送
|
2026-04-19 12:14:10 +08:00
|
|
|
|
_sendStartedAt = 0;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── 设置房间公告 ─────────────────────────────────────
|
2026-03-12 07:33:32 +08:00
|
|
|
|
function promptAnnouncement() {
|
|
|
|
|
|
// 从 marquee 读取当前公告全文,剥离末尾的「——发送者 日期」元信息,仅预填纯内容
|
|
|
|
|
|
const fullText = document.getElementById('announcement-text')?.textContent?.trim() || '';
|
|
|
|
|
|
const pureText = fullText.replace(/ ——\S+ \d{2}-\d{2} \d{2}:\d{2}$/, '').trim();
|
|
|
|
|
|
// 使用全局弹窗替代原生 prompt(),通过 .then() 注册回调确保事件正确触发
|
|
|
|
|
|
window.chatDialog.prompt('请输入新的房间公告/祝福语:', pureText, '设置公告', '#336699').then(newText => {
|
|
|
|
|
|
if (newText === null || newText.trim() === '') return;
|
|
|
|
|
|
|
|
|
|
|
|
fetch(`/room/${window.chatContext.roomId}/announcement`, {
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
2026-03-12 07:33:32 +08:00
|
|
|
|
}).then(res => res.json()).then(data => {
|
|
|
|
|
|
if (data.status === 'success') {
|
|
|
|
|
|
// 用后端返回的含发送者+时间的完整公告文本更新滚动条
|
|
|
|
|
|
const marquee = document.getElementById('announcement-text');
|
|
|
|
|
|
if (marquee) marquee.textContent = data.announcement;
|
|
|
|
|
|
window.chatDialog.alert('公告已更新!', '提示', '#16a34a');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.chatDialog.alert(data.message || '更新失败', '操作失败', '#cc4444');
|
|
|
|
|
|
}
|
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
|
window.chatDialog.alert('设置公告失败:' + e.message, '操作失败', '#cc4444');
|
2026-02-26 21:10:34 +08:00
|
|
|
|
});
|
2026-03-12 07:33:32 +08:00
|
|
|
|
});
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 22:27:49 +08:00
|
|
|
|
// ── 站长公屏讲话 ─────────────────────────────────────
|
2026-03-12 07:33:32 +08:00
|
|
|
|
function promptAnnounceMessage() {
|
|
|
|
|
|
// 使用全局弹窗替代原生 prompt(),通过 .then() 注册回调确保事件正确触发
|
|
|
|
|
|
window.chatDialog.prompt('请输入公屏讲话内容:', '', '📢 公屏讲话', '#7c3aed').then(content => {
|
|
|
|
|
|
if (!content || !content.trim()) return;
|
2026-02-26 22:27:49 +08:00
|
|
|
|
|
2026-03-12 07:33:32 +08:00
|
|
|
|
fetch('/command/announce', {
|
2026-02-26 22:27:49 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-03-12 07:33:32 +08:00
|
|
|
|
}).then(res => res.json()).then(data => {
|
|
|
|
|
|
if (data.status !== 'success') {
|
|
|
|
|
|
window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
|
|
|
|
|
|
}
|
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
|
window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
|
2026-02-26 22:27:49 +08:00
|
|
|
|
});
|
2026-03-12 07:33:32 +08:00
|
|
|
|
});
|
2026-02-26 22:27:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 23:05:56 +08:00
|
|
|
|
// ── 管理员全员清屏 ─────────────────────────────────────
|
2026-03-12 07:33:32 +08:00
|
|
|
|
function adminClearScreen() {
|
|
|
|
|
|
// 使用全局弹窗替代原生 confirm(),通过 .then() 注册回调确保事件正确触发
|
|
|
|
|
|
window.chatDialog.confirm('确定要清除所有人的聊天记录吗?(悄悄话将保留)', '全员清屏', '#dc2626').then(ok => {
|
|
|
|
|
|
if (!ok) return;
|
2026-02-26 23:05:56 +08:00
|
|
|
|
|
2026-03-12 07:33:32 +08:00
|
|
|
|
fetch('/command/clear-screen', {
|
2026-02-26 23:05:56 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-03-12 07:33:32 +08:00
|
|
|
|
}).then(res => res.json()).then(data => {
|
|
|
|
|
|
if (data.status !== 'success') {
|
|
|
|
|
|
window.chatDialog.alert(data.message || '清屏失败', '操作失败', '#cc4444');
|
|
|
|
|
|
}
|
|
|
|
|
|
}).catch(e => {
|
|
|
|
|
|
window.chatDialog.alert('清屏失败:' + e.message, '操作失败', '#cc4444');
|
2026-02-26 23:05:56 +08:00
|
|
|
|
});
|
2026-03-12 07:33:32 +08:00
|
|
|
|
});
|
2026-02-26 23:05:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 11:20:34 +08:00
|
|
|
|
// ── 本地清屏(仅限自己的屏幕)───────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// ── 滚屏开关 ─────────────────────────────────────
|
|
|
|
|
|
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 ? '开' : '关';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 退出房间 ─────────────────────────────────────
|
2026-04-11 22:40:42 +08:00
|
|
|
|
let leaveRequestInFlight = false;
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
async function leaveRoom() {
|
2026-04-11 22:40:42 +08:00
|
|
|
|
if (leaveRequestInFlight) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
leaveRequestInFlight = true;
|
2026-03-01 00:16:34 +08:00
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
try {
|
2026-04-02 16:21:35 +08:00
|
|
|
|
await fetch(window.chatContext.leaveUrl + '?explicit=1', {
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 22:40:42 +08:00
|
|
|
|
async function notifyExpiredLeave() {
|
|
|
|
|
|
if (leaveRequestInFlight) {
|
2026-03-01 00:12:47 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 22:40:42 +08:00
|
|
|
|
leaveRequestInFlight = true;
|
2026-03-01 00:12:47 +08:00
|
|
|
|
|
2026-04-11 22:40:42 +08:00
|
|
|
|
try {
|
|
|
|
|
|
if (!window.chatContext?.expiredLeaveUrl) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-01 00:12:47 +08:00
|
|
|
|
|
2026-04-11 22:40:42 +08:00
|
|
|
|
await fetch(window.chatContext.expiredLeaveUrl, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Accept': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-01 00:18:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-26 21:10:34 +08:00
|
|
|
|
// ── 掉线检测计数器 ──
|
|
|
|
|
|
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) {
|
2026-04-11 22:40:42 +08:00
|
|
|
|
await notifyExpiredLeave();
|
2026-03-01 01:32:20 +08:00
|
|
|
|
window.chatDialog.alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。', '连接警告', '#b45309');
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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;
|
2026-02-27 00:49:35 +08:00
|
|
|
|
const levelTitle = d.title || '普通会员';
|
2026-02-26 21:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
let levelInfo = '';
|
|
|
|
|
|
if (d.is_max_level) {
|
2026-02-27 00:44:45 +08:00
|
|
|
|
levelInfo = `级别(${d.user_level});经验(${d.exp_num});金币(${d.jjb}枚);已满级。`;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
} else {
|
2026-02-27 00:44:45 +08:00
|
|
|
|
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(', ')})`;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 =
|
2026-02-27 00:44:45 +08:00
|
|
|
|
`<span style="color: green;">【${levelTitle}存点】您的最新情况:${levelInfo} ${gainInfo}</span><span class="msg-time">(${timeStr})</span>`;
|
2026-02-26 21:10:34 +08:00
|
|
|
|
container2.appendChild(detailDiv);
|
|
|
|
|
|
if (autoScroll) container2.scrollTop = container2.scrollHeight;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('存点失败', e);
|
|
|
|
|
|
heartbeatFailCount++;
|
|
|
|
|
|
|
|
|
|
|
|
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
|
2026-03-01 01:32:20 +08:00
|
|
|
|
window.chatDialog.alert('⚠️ 与服务器的连接已断开,请检查网络后重新登录。', '连接警告', '#b45309');
|
2026-02-26 21:10:34 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-02-26 21:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* HTML 转义函数,防止 XSS
|
|
|
|
|
|
*/
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.textContent = text;
|
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
|
}
|
2026-04-19 14:43:02 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 规整广播携带的链接,只允许当前站点的 http(s) 地址进入 innerHTML。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function normalizeSafeChatUrl(url, fallback) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsedUrl = new URL(url || fallback, window.location.origin);
|
|
|
|
|
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsedUrl.origin !== window.location.origin) {
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return parsedUrl.toString();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-01 22:20:54 +08:00
|
|
|
|
</script>
|