新增聊天室状态与功能快捷菜单

This commit is contained in:
2026-04-24 21:17:44 +08:00
parent d7ec42a025
commit 0f0bfef2a8
18 changed files with 1361 additions and 124 deletions
+580 -24
View File
@@ -72,6 +72,7 @@
})();
let onlineUsers = {};
let autoScroll = true;
let userBadgeRotationTick = 0;
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {});
let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
@@ -94,6 +95,394 @@
};
}
/**
* 解析并标准化状态到期时间。
*
* @param {string|null|undefined} expiresAt 原始到期时间
* @returns {Date|null}
*/
function parseDailyStatusExpiry(expiresAt) {
if (!expiresAt) {
return null;
}
const parsed = new Date(expiresAt);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* 将状态对象规整为前端统一结构,并过滤掉已过期状态。
*
* @param {Record<string, any>|null|undefined} raw 原始状态对象
* @returns {Object|null}
*/
function normalizeDailyStatus(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const key = String(raw.key ?? raw.daily_status_key ?? '');
const label = String(raw.label ?? raw.daily_status_label ?? '');
const icon = String(raw.icon ?? raw.daily_status_icon ?? '');
const group = String(raw.group ?? raw.daily_status_group ?? '');
const expiresAt = raw.expires_at ?? raw.daily_status_expires_at ?? null;
const parsedExpiry = parseDailyStatusExpiry(expiresAt);
if (!key || !label || !icon || !parsedExpiry) {
return null;
}
if (parsedExpiry.getTime() <= Date.now()) {
return null;
}
return {
key,
label,
icon,
group,
expires_at: parsedExpiry.toISOString(),
};
}
/**
* 获取当前登录用户仍然有效的状态。
*
* @returns {Object|null}
*/
function getCurrentUserDailyStatus() {
return normalizeDailyStatus(window.chatContext?.currentDailyStatus);
}
/**
* 清除用户在线载荷中的状态字段,避免合并时残留旧状态。
*
* @param {Record<string, any>} payload 用户在线载荷
*/
function removeDailyStatusFields(payload) {
if (!payload || typeof payload !== 'object') {
return;
}
delete payload.daily_status_key;
delete payload.daily_status_label;
delete payload.daily_status_icon;
delete payload.daily_status_group;
delete payload.daily_status_expires_at;
}
/**
* 将状态写回指定用户的在线载荷。
*
* @param {string} username 用户名
* @param {Object|null} status 标准化后的状态对象
*/
function setOnlineUserDailyStatus(username, status) {
if (!username || !onlineUsers[username]) {
return;
}
removeDailyStatusFields(onlineUsers[username]);
if (!status) {
return;
}
onlineUsers[username].daily_status_key = status.key;
onlineUsers[username].daily_status_label = status.label;
onlineUsers[username].daily_status_icon = status.icon;
onlineUsers[username].daily_status_group = status.group;
onlineUsers[username].daily_status_expires_at = status.expires_at;
}
/**
* 用服务端最新的在线载荷刷新指定用户,并先清空旧状态字段。
*
* @param {string} username 用户名
* @param {Record<string, any>} payload 最新在线载荷
*/
function hydrateOnlineUserPayload(username, payload) {
const nextPayload = {
...(onlineUsers[username] || {}),
};
removeDailyStatusFields(nextPayload);
onlineUsers[username] = {
...nextPayload,
...payload,
};
}
/**
* 同步状态按钮文字与图标。
*/
function syncDailyStatusTrigger() {
const shortcutIcon = document.getElementById('daily-status-shortcut-icon');
const shortcutLabel = document.getElementById('daily-status-shortcut-label');
const activeStatus = getCurrentUserDailyStatus();
if (shortcutIcon) {
shortcutIcon.textContent = activeStatus?.icon || '🙂';
}
if (shortcutLabel) {
shortcutLabel.textContent = activeStatus?.label || '状态';
}
}
/**
* 同步状态面板中当前选中项的高亮样式。
*/
function syncDailyStatusMenuSelection() {
const activeKey = getCurrentUserDailyStatus()?.key || '';
document.querySelectorAll('#daily-status-editor-overlay .daily-status-item').forEach((button) => {
const selected = button.dataset.statusKey === activeKey;
button.style.borderColor = selected ? '#6366f1' : '#e5e7eb';
button.style.background = selected ? 'linear-gradient(180deg,#eef2ff 0%,#e0e7ff 100%)' : '#ffffffcc';
button.style.color = selected ? '#312e81' : '#334155';
button.style.boxShadow = selected ? '0 8px 18px rgba(99,102,241,.18)' : 'none';
button.style.transform = selected ? 'translateY(-1px)' : 'translateY(0)';
});
}
/**
* 同步聊天室状态相关 UI。
*/
function syncDailyStatusUi() {
const activeStatus = getCurrentUserDailyStatus();
if (window.chatContext) {
window.chatContext.currentDailyStatus = activeStatus;
}
syncDailyStatusTrigger();
syncDailyStatusMenuSelection();
}
/**
* 关闭功能菜单。
*/
function closeFeatureMenu() {
const menu = document.getElementById('feature-menu');
if (menu) {
menu.style.display = 'none';
}
}
/**
* 切换功能菜单显示状态。
*
* @param {Event} event 点击事件
*/
function toggleFeatureMenu(event) {
event.stopPropagation();
const menu = document.getElementById('feature-menu');
const welcomeMenu = document.getElementById('welcome-menu');
const adminMenu = document.getElementById('admin-menu');
const blockMenu = document.getElementById('block-menu');
const editorOverlay = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
}
if (welcomeMenu) {
welcomeMenu.style.display = 'none';
}
if (adminMenu) {
adminMenu.style.display = 'none';
}
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (editorOverlay) {
editorOverlay.style.display = 'none';
}
syncDailyStatusUi();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
/**
* 打开状态编辑窗口。
*/
function openDailyStatusEditor() {
const overlay = document.getElementById('daily-status-editor-overlay');
closeFeatureMenu();
syncDailyStatusUi();
if (overlay) {
overlay.style.display = 'block';
}
}
/**
* 关闭状态编辑窗口。
*/
function closeDailyStatusEditor() {
const overlay = document.getElementById('daily-status-editor-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
/**
* 执行功能菜单中的快捷入口,并在打开目标面板前先关闭功能菜单。
*
* @param {string} action 快捷入口动作名
*/
function runFeatureShortcut(action) {
closeFeatureMenu();
if (action === 'shop' && typeof window.openShopModal === 'function') {
window.openShopModal();
return;
}
if (action === 'vip' && typeof window.openVipModal === 'function') {
window.openVipModal();
return;
}
if (action === 'game' && typeof window.openGameHall === 'function') {
window.openGameHall();
return;
}
if (action === 'avatar' && typeof window.openAvatarPicker === 'function') {
window.openAvatarPicker();
return;
}
if (action === 'bank' && typeof window.openBankModal === 'function') {
window.openBankModal();
return;
}
if (action === 'marriage' && typeof window.openMarriageStatusModal === 'function') {
window.openMarriageStatusModal();
return;
}
if (action === 'friend' && typeof window.openFriendPanel === 'function') {
window.openFriendPanel();
return;
}
if (action === 'settings' && typeof window.openSettingsModal === 'function') {
window.openSettingsModal();
}
}
/**
* 提交状态设置/清除请求。
*
* @param {Object} payload 请求载荷
* @returns {Promise<Object>}
*/
async function submitDailyStatusPayload(payload) {
const response = await fetch(window.chatContext.dailyStatusUpdateUrl, {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok || result?.status !== 'success') {
throw new Error(result?.message || '状态保存失败');
}
return result;
}
/**
* 将服务端返回的状态结果应用到当前用户本地名单。
*
* @param {Object|null} status 标准化后的状态对象
*/
function applyCurrentUserDailyStatus(status) {
if (window.chatContext) {
window.chatContext.currentDailyStatus = status;
}
setOnlineUserDailyStatus(window.chatContext.username, status);
syncDailyStatusUi();
renderUserList();
}
/**
* 设置新的当日状态。
*
* @param {string} statusKey 状态键
*/
async function updateDailyStatus(statusKey) {
if (!window.chatContext?.dailyStatusUpdateUrl) {
return;
}
try {
const result = await submitDailyStatusPayload({
room_id: window.chatContext.roomId,
action: 'set',
status_key: statusKey,
});
const status = normalizeDailyStatus(result?.data?.status);
applyCurrentUserDailyStatus(status);
closeDailyStatusEditor();
window.chatToast?.show({
title: '状态已更新',
message: status ? `${status.icon} ${status.label}` : '已更新',
icon: status?.icon || '🙂',
color: '#4f46e5',
duration: 2600,
});
} catch (error) {
window.chatDialog?.alert(error.message || '状态设置失败', '操作失败', '#cc4444');
}
}
/**
* 清除当前当日状态。
*/
async function clearDailyStatus() {
if (!window.chatContext?.dailyStatusUpdateUrl) {
return;
}
try {
await submitDailyStatusPayload({
room_id: window.chatContext.roomId,
action: 'clear',
});
applyCurrentUserDailyStatus(null);
closeDailyStatusEditor();
window.chatToast?.show({
title: '状态已清除',
message: '名字后方将恢复默认徽标展示。',
icon: '♻️',
color: '#c2410c',
duration: 2400,
});
} catch (error) {
window.chatDialog?.alert(error.message || '状态清除失败', '操作失败', '#cc4444');
}
}
/**
* localStorage 读取已屏蔽的系统播报发送者列表。
*
@@ -308,6 +697,8 @@
const menu = document.getElementById('block-menu');
const welcomeMenu = document.getElementById('welcome-menu');
const adminMenu = document.getElementById('admin-menu');
const featureMenu = document.getElementById('feature-menu');
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
@@ -321,6 +712,14 @@
adminMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
syncBlockedSystemSenderCheckboxes();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
@@ -615,6 +1014,8 @@
const menu = document.getElementById('welcome-menu');
const adminMenu = document.getElementById('admin-menu');
const blockMenu = document.getElementById('block-menu');
const featureMenu = document.getElementById('feature-menu');
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
}
@@ -624,6 +1025,12 @@
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
@@ -635,6 +1042,8 @@
const menu = document.getElementById('admin-menu');
const welcomeMenu = document.getElementById('welcome-menu');
const blockMenu = document.getElementById('block-menu');
const featureMenu = document.getElementById('feature-menu');
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
}
@@ -644,6 +1053,12 @@
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
@@ -803,6 +1218,16 @@
if (blockMenu) {
blockMenu.style.display = 'none';
}
const featureMenu = document.getElementById('feature-menu');
if (featureMenu) {
featureMenu.style.display = 'none';
}
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
});
// ── 动作选择 ──────────────────────────────────────
@@ -986,29 +1411,14 @@
const headImgSrc = headface.startsWith('storage/') ? '/' + headface : '/images/headface/' +
headface;
// 徽章优先级:职务图标 > 管理员 > VIP
let badges = '';
if (user.position_icon) {
const posTitle = (user.position_name || '在职') + ' · ' + username;
const safePosTitle = escapeHtml(String(posTitle));
const safePositionIcon = escapeHtml(String(user.position_icon || '🎖️'));
badges +=
`<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
} else if (user.is_admin) {
badges += `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
} else if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
badges +=
`<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
}
const badges = buildUserBadgeHtml(user, username);
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
item.innerHTML = `
<img class="user-head" src="${headImgSrc}" onerror="this.src='/images/headface/1.gif'">
<span class="user-name" style="${nameColor}">${username}</span>${badges}
<span class="user-name" style="${nameColor}">${username}</span>
<span class="user-badge-slot">${badges}</span>
`;
// 单击/双击互斥:单击延迟 250ms 执行,双击取消单击定时器后直接执行双击逻辑
@@ -1048,6 +1458,8 @@
}, { passive: false });
targetContainer.appendChild(item);
});
refreshRenderedUserBadges(targetContainer);
}
function renderUserList() {
@@ -1089,6 +1501,115 @@
window.dispatchEvent(new Event('chatroom:users-updated'));
}
/**
* 获取用户当前仍然有效的当日状态。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {Object|null}
*/
function resolveUserDailyStatus(user) {
return normalizeDailyStatus(user);
}
/**
* 构建原有徽标(职务 / 管理员 / VIP)。
*
* @param {Record<string, any>} user 用户在线载荷
* @param {string} username 用户名
* @returns {string}
*/
function buildUserPrimaryBadgeHtml(user, username) {
if (user.position_icon) {
const posTitle = (user.position_name || '在职') + ' · ' + username;
const safePosTitle = escapeHtml(String(posTitle));
const safePositionIcon = escapeHtml(String(user.position_icon || '🎖️'));
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
}
if (user.is_admin) {
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
}
if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
}
return '';
}
/**
* 构建状态徽标。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {string}
*/
function buildUserStatusBadgeHtml(user) {
const status = resolveUserDailyStatus(user);
if (!status) {
return '';
}
const safeIcon = escapeHtml(status.icon);
const safeLabel = escapeHtml(status.label);
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
return `
<span style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;border-radius:999px;background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca;font-size:11px;line-height:18px;vertical-align:middle;"
data-instant-tooltip="${safeTooltip}">
<span style="font-size:11px;line-height:1;">${safeIcon}</span>
<span style="line-height:1;">${safeLabel}</span>
</span>
`;
}
/**
* 3 秒节奏在原有徽标与状态徽标之间切换。
*
* @param {Record<string, any>} user 用户在线载荷
* @param {string} username 用户名
* @returns {string}
*/
function buildUserBadgeHtml(user, username) {
const statusBadge = buildUserStatusBadgeHtml(user);
const primaryBadge = buildUserPrimaryBadgeHtml(user, username);
if (statusBadge && primaryBadge) {
return userBadgeRotationTick % 2 === 0 ? statusBadge : primaryBadge;
}
return statusBadge || primaryBadge;
}
/**
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
*
* @param {HTMLElement|Document} scope 需要刷新的 DOM 范围
*/
function refreshRenderedUserBadges(scope = document) {
scope.querySelectorAll('.user-item[data-username]').forEach((item) => {
const username = item.dataset.username;
const badgeSlot = item.querySelector('.user-badge-slot');
if (!username || !badgeSlot) {
return;
}
badgeSlot.innerHTML = buildUserBadgeHtml(onlineUsers[username] || {}, username);
});
}
// 名单中“状态 / 原徽标”双轨展示时,每 3 秒只刷新徽标槽位,不重建头像行。
window.setInterval(() => {
userBadgeRotationTick = (userBadgeRotationTick + 1) % 2;
refreshRenderedUserBadges();
syncDailyStatusUi();
}, 3000);
/**
* 搜索/过滤用户列表
*/
@@ -1467,17 +1988,17 @@
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
hydrateOnlineUserPayload(u.username, u);
});
// 初始加载时,如果全局且开启,注入 AI
if (window.chatContext.chatBotEnabled && window.chatContext.botUser) {
onlineUsers['AI小班长'] = window.chatContext.botUser;
hydrateOnlineUserPayload('AI小班长', window.chatContext.botUser);
}
setOnlineUserDailyStatus(window.chatContext.username, getCurrentUserDailyStatus());
syncDailyStatusUi();
renderUserList();
});
// 监听机器人动态开关
@@ -1486,7 +2007,7 @@
window.chatContext.chatBotEnabled = detail.isOnline;
if (detail.isOnline && detail.user && detail.user.username) {
onlineUsers[detail.user.username] = detail.user;
hydrateOnlineUserPayload(detail.user.username, detail.user);
window.chatContext.botUser = detail.user;
} else {
delete onlineUsers['AI小班长'];
@@ -1495,9 +2016,27 @@
renderUserList();
});
window.addEventListener('chat:user-status-updated', (e) => {
const username = e.detail?.username;
const payload = e.detail?.user;
if (!username || !payload) {
return;
}
hydrateOnlineUserPayload(username, payload);
if (username === window.chatContext.username) {
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
syncDailyStatusUi();
}
renderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
hydrateOnlineUserPayload(user.username, user);
renderUserList();
});
@@ -1904,10 +2443,19 @@
}
window.toggleAdminMenu = toggleAdminMenu;
window.toggleBlockMenu = toggleBlockMenu;
window.toggleFeatureMenu = toggleFeatureMenu;
window.closeFeatureMenu = closeFeatureMenu;
window.openDailyStatusEditor = openDailyStatusEditor;
window.closeDailyStatusEditor = closeDailyStatusEditor;
window.runFeatureShortcut = runFeatureShortcut;
window.runAdminAction = runAdminAction;
window.selectEffect = selectEffect;
window.triggerEffect = triggerEffect;
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
window.updateDailyStatus = updateDailyStatus;
window.clearDailyStatus = clearDailyStatus;
window.handleFeatureLocalClear = handleFeatureLocalClear;
syncDailyStatusUi();
// ── 字号设置(持久化到 localStorage)─────────────────
/**
@@ -2480,6 +3028,14 @@
}
}
/**
* 在状态面板中触发本地清屏,并顺手关闭面板。
*/
function handleFeatureLocalClear() {
closeFeatureMenu();
localClearScreen();
}
// ── 滚屏开关 ─────────────────────────────────────
function toggleAutoScroll() {
autoScroll = !autoScroll;