344 lines
12 KiB
JavaScript
344 lines
12 KiB
JavaScript
|
|
// 聊天室在线用户列表渲染:名单、搜索、徽标轮换。
|
||
|
|
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||
|
|
|
||
|
|
import { escapeHtml } from "./html.js";
|
||
|
|
import { normalizeDailyStatus } from "./preferences-status.js";
|
||
|
|
|
||
|
|
// ── 每日状态解析 ──
|
||
|
|
function resolveUserDailyStatus(user) {
|
||
|
|
return normalizeDailyStatus(user);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 构建职务 / 管理员徽标 ──
|
||
|
|
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>`;
|
||
|
|
}
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 构建用户 VIP 徽标 ──
|
||
|
|
function buildUserVipBadgeHtml(user) {
|
||
|
|
if (!user.vip_icon) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
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>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 构建状态徽标 ──
|
||
|
|
function buildUserStatusBadgeHtml(user) {
|
||
|
|
const status = resolveUserDailyStatus(user);
|
||
|
|
if (!status) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
const safeIcon = escapeHtml(status.icon);
|
||
|
|
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
|
||
|
|
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 构建签到身份徽标 ──
|
||
|
|
function buildUserSignIdentityBadgeHtml(user) {
|
||
|
|
const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? "");
|
||
|
|
const identityIcon = String(user.sign_identity_icon ?? "");
|
||
|
|
if (!identityKey || !identityIcon) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? "");
|
||
|
|
const safeIcon = escapeHtml(identityIcon);
|
||
|
|
const safeTooltip = escapeHtml(identityLabel ? `签到 · ${identityLabel}` : "签到身份");
|
||
|
|
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 按轮换节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。
|
||
|
|
*/
|
||
|
|
export function buildUserBadgeHtml(user, username) {
|
||
|
|
const state = window.chatState;
|
||
|
|
const tick = state ? state.userBadgeRotationTick : 0;
|
||
|
|
|
||
|
|
const badges = [
|
||
|
|
buildUserSignIdentityBadgeHtml(user),
|
||
|
|
buildUserStatusBadgeHtml(user),
|
||
|
|
buildUserPrimaryBadgeHtml(user, username),
|
||
|
|
buildUserVipBadgeHtml(user),
|
||
|
|
].filter(Boolean);
|
||
|
|
|
||
|
|
if (badges.length === 0) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
return badges[tick % badges.length];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
|
||
|
|
*/
|
||
|
|
export function refreshRenderedUserBadges(scope = document) {
|
||
|
|
const state = window.chatState;
|
||
|
|
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 核心渲染函数:将在线用户渲染到指定容器(桌面端名单区和手机端抽屉共用)。
|
||
|
|
*/
|
||
|
|
export function renderUserListToContainer(targetContainer, sortBy, keyword) {
|
||
|
|
if (!targetContainer) return;
|
||
|
|
|
||
|
|
const state = window.chatState;
|
||
|
|
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
|
||
|
|
const fragment = document.createDocumentFragment();
|
||
|
|
|
||
|
|
// 在列表顶部添加"大家"条目
|
||
|
|
const allDiv = document.createElement("div");
|
||
|
|
allDiv.className = "user-item";
|
||
|
|
allDiv.dataset.userListEveryone = "1";
|
||
|
|
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
|
||
|
|
fragment.appendChild(allDiv);
|
||
|
|
|
||
|
|
// 构建用户数组并排序
|
||
|
|
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;
|
||
|
|
|
||
|
|
// 搜索过滤
|
||
|
|
if (keyword && !username.toLowerCase().includes(keyword)) return;
|
||
|
|
|
||
|
|
const item = document.createElement("div");
|
||
|
|
item.className = "user-item";
|
||
|
|
item.dataset.username = username;
|
||
|
|
item.dataset.userListEntry = "1";
|
||
|
|
|
||
|
|
const headface = (user.headface || "1.gif");
|
||
|
|
const headImgSrc = headface.startsWith("storage/") ? "/" + headface : "/images/headface/" + headface;
|
||
|
|
|
||
|
|
const badges = buildUserBadgeHtml(user, username);
|
||
|
|
|
||
|
|
// 女生名字使用玫粉色
|
||
|
|
const nameColor = (user.sex == 2) ? "color:#e91e8c;" : "";
|
||
|
|
|
||
|
|
// 昵称颜色装扮
|
||
|
|
let userNameExtraClass = "";
|
||
|
|
if (user.name_color) {
|
||
|
|
userNameExtraClass = " msg-name--" + user.name_color.replace(/^msg_name_/, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
// 头像框装扮
|
||
|
|
let avatarHtml = "";
|
||
|
|
if (user.avatar_frame) {
|
||
|
|
const frameClass = "avatar-frame--" + user.avatar_frame.replace(/^avatar_frame_/, "");
|
||
|
|
avatarHtml = '<span class="avatar-frame-wrapper">' +
|
||
|
|
'<span class="avatar-frame ' + frameClass + '"></span>' +
|
||
|
|
'<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
|
||
|
|
'</span>';
|
||
|
|
} else {
|
||
|
|
avatarHtml = '<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">';
|
||
|
|
}
|
||
|
|
|
||
|
|
item.innerHTML = `
|
||
|
|
${avatarHtml}
|
||
|
|
<span class="user-name${userNameExtraClass}" style="${nameColor}">${username}</span>
|
||
|
|
<span class="user-badge-slot">${badges}</span>
|
||
|
|
`;
|
||
|
|
|
||
|
|
// 具体点击、双击与手机双触发由 Vite 的 right-panel.js 统一事件委托处理
|
||
|
|
fragment.appendChild(item);
|
||
|
|
});
|
||
|
|
|
||
|
|
targetContainer.replaceChildren(fragment);
|
||
|
|
refreshRenderedUserBadges(targetContainer);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 渲染完整用户列表(含下拉选单与在线计数)。
|
||
|
|
*/
|
||
|
|
export function renderUserList() {
|
||
|
|
const state = window.chatState;
|
||
|
|
if (!state) return;
|
||
|
|
|
||
|
|
if (state.userListRenderTimer) {
|
||
|
|
window.clearTimeout(state.userListRenderTimer);
|
||
|
|
state.userListRenderTimer = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const userList = state.userList;
|
||
|
|
const toUserSelect = state.toUserSelect;
|
||
|
|
|
||
|
|
// 获取排序方式和搜索词
|
||
|
|
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() : "";
|
||
|
|
|
||
|
|
// 调用核心渲染
|
||
|
|
if (userList) {
|
||
|
|
renderUserListToContainer(userList, sortBy, keyword);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 下拉框重建
|
||
|
|
if (toUserSelect) {
|
||
|
|
const selectFragment = document.createDocumentFragment();
|
||
|
|
const everyoneOption = document.createElement("option");
|
||
|
|
everyoneOption.value = "大家";
|
||
|
|
everyoneOption.textContent = "大家";
|
||
|
|
selectFragment.appendChild(everyoneOption);
|
||
|
|
|
||
|
|
for (let username in state.onlineUsers) {
|
||
|
|
if (username !== window.chatContext?.username) {
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = username;
|
||
|
|
option.textContent = username === "AI小班长" ? "🤖 AI小班长" : username;
|
||
|
|
selectFragment.appendChild(option);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
toUserSelect.replaceChildren(selectFragment);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 在线计数
|
||
|
|
const count = Object.keys(state.onlineUsers).length;
|
||
|
|
const onlineCount = state.onlineCount;
|
||
|
|
const onlineCountBottom = state.onlineCountBottom;
|
||
|
|
if (onlineCount) onlineCount.innerText = count;
|
||
|
|
if (onlineCountBottom) onlineCountBottom.innerText = count;
|
||
|
|
const footer = document.getElementById("online-count-footer");
|
||
|
|
if (footer) footer.innerText = count;
|
||
|
|
|
||
|
|
// 派发用户列表更新事件,供手机端抽屉同步
|
||
|
|
window.dispatchEvent(new Event("chatroom:users-updated"));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 合并高频在线名单变动,避免 Presence 连续进出时重复重建名单 DOM。
|
||
|
|
*/
|
||
|
|
export function scheduleRenderUserList(delay = 120) {
|
||
|
|
const state = window.chatState;
|
||
|
|
if (!state) return;
|
||
|
|
|
||
|
|
if (state.userListRenderTimer) {
|
||
|
|
window.clearTimeout(state.userListRenderTimer);
|
||
|
|
}
|
||
|
|
state.userListRenderTimer = window.setTimeout(() => {
|
||
|
|
state.userListRenderTimer = null;
|
||
|
|
renderUserList();
|
||
|
|
}, delay);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 搜索/过滤用户列表(仅操作 DOM 可见性,不重建)。
|
||
|
|
*/
|
||
|
|
export function filterUserList() {
|
||
|
|
const searchInput = document.getElementById("user-search-input");
|
||
|
|
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
|
||
|
|
const state = window.chatState;
|
||
|
|
const userList = state?.userList || document.getElementById("online-users-list");
|
||
|
|
if (!userList) return;
|
||
|
|
|
||
|
|
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";
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 调度用户列表搜索过滤,避免每个按键都同步扫描名单 DOM。
|
||
|
|
*/
|
||
|
|
export function scheduleFilterUserList() {
|
||
|
|
const state = window.chatState;
|
||
|
|
if (!state) return;
|
||
|
|
|
||
|
|
if (state.userFilterRenderTimer !== null) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const scheduleFilter = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
|
||
|
|
state.userFilterRenderTimer = scheduleFilter(() => {
|
||
|
|
state.userFilterRenderTimer = null;
|
||
|
|
filterUserList();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 徽标旋转定时器 ──
|
||
|
|
let badgeRotationInterval = null;
|
||
|
|
|
||
|
|
export function startBadgeRotation() {
|
||
|
|
if (badgeRotationInterval) return;
|
||
|
|
|
||
|
|
badgeRotationInterval = window.setInterval(() => {
|
||
|
|
if (document.hidden) return;
|
||
|
|
|
||
|
|
const state = window.chatState;
|
||
|
|
if (!state) return;
|
||
|
|
|
||
|
|
state.userBadgeRotationTick = (state.userBadgeRotationTick + 1) % 4;
|
||
|
|
|
||
|
|
if (state.userList) {
|
||
|
|
refreshRenderedUserBadges(state.userList);
|
||
|
|
}
|
||
|
|
const mobileUsersList = document.getElementById("mob-online-users-list");
|
||
|
|
if (mobileUsersList?.offsetParent !== null) {
|
||
|
|
refreshRenderedUserBadges(mobileUsersList);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 同步每日状态 UI
|
||
|
|
if (typeof window.syncDailyStatusUi === "function") {
|
||
|
|
window.syncDailyStatusUi();
|
||
|
|
}
|
||
|
|
}, 3000);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function stopBadgeRotation() {
|
||
|
|
if (badgeRotationInterval) {
|
||
|
|
window.clearInterval(badgeRotationInterval);
|
||
|
|
badgeRotationInterval = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 挂载到 window ──
|
||
|
|
window.renderUserList = renderUserList;
|
||
|
|
window.renderUserListToContainer = renderUserListToContainer;
|
||
|
|
window.filterUserList = filterUserList;
|
||
|
|
window.scheduleFilterUserList = scheduleFilterUserList;
|
||
|
|
window.scheduleRenderUserList = scheduleRenderUserList;
|
||
|
|
window.refreshRenderedUserBadges = refreshRenderedUserBadges;
|
||
|
|
window.buildUserBadgeHtml = buildUserBadgeHtml;
|
||
|
|
window._renderUserListToContainer = renderUserListToContainer;
|
||
|
|
|
||
|
|
export {
|
||
|
|
buildUserPrimaryBadgeHtml,
|
||
|
|
buildUserVipBadgeHtml,
|
||
|
|
buildUserStatusBadgeHtml,
|
||
|
|
buildUserSignIdentityBadgeHtml,
|
||
|
|
resolveUserDailyStatus,
|
||
|
|
};
|