// 聊天室偏好与每日状态工具,承接从 Blade 内联脚本迁移出的纯数据规整逻辑。 export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"]; export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders"; export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted"; // 白名单、localStorage key 与绑定标记共同保证偏好读取可控、事件只注册一次。 let soundMuteEventsBound = false; let blockMenuEventsBound = false; /** * 规整聊天室偏好对象,过滤非法配置并补齐默认值。 * * @param {Record|null|undefined} raw * @param {string[]} blockableSystemSenders * @returns {{blocked_system_senders:string[],sound_muted:boolean}} */ export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) { // 服务端或旧本地缓存可能包含已下架发送者,规整时只保留当前白名单。 const blocked = Array.isArray(raw?.blocked_system_senders) ? raw.blocked_system_senders.filter((sender) => blockableSystemSenders.includes(sender)) : []; return { blocked_system_senders: Array.from(new Set(blocked)), sound_muted: Boolean(raw?.sound_muted), }; } /** * 解析并标准化状态到期时间。 * * @param {string|null|undefined} expiresAt * @returns {Date|null} */ export function parseDailyStatusExpiry(expiresAt) { if (!expiresAt) { return null; } const parsed = new Date(expiresAt); return Number.isNaN(parsed.getTime()) ? null : parsed; } /** * 将状态对象规整为前端统一结构,并过滤掉已过期状态。 * * @param {Record|null|undefined} raw * @param {number} nowTimestamp * @returns {{key:string,label:string,icon:string,group:string,expires_at:string}|null} */ export function normalizeDailyStatus(raw, nowTimestamp = Date.now()) { 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() <= nowTimestamp) { return null; } return { key, label, icon, group, expires_at: parsedExpiry.toISOString(), }; } /** * 从本地缓存读取已屏蔽的系统播报发送者列表。 * * @param {string[]} blockableSystemSenders * @returns {string[]} */ export function loadBlockedSystemSenders(blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) { try { // 旧 localStorage 可能损坏或被手动篡改,读取后只保留当前允许屏蔽的发送者。 const saved = JSON.parse(localStorage.getItem(BLOCKED_SYSTEM_SENDERS_STORAGE_KEY) || "[]"); if (!Array.isArray(saved)) { return []; } return saved.filter((sender) => blockableSystemSenders.includes(sender)); } catch (error) { return []; } } /** * 将屏蔽的系统播报发送者列表写入本地缓存。 * * @param {Iterable} senders * @returns {void} */ export function persistBlockedSystemSenders(senders) { localStorage.setItem(BLOCKED_SYSTEM_SENDERS_STORAGE_KEY, JSON.stringify(Array.from(senders))); } /** * 判断当前禁音开关是否处于打开状态。 * * @returns {boolean} */ export function isSoundMuted() { const muteCheckbox = document.getElementById("sound_muted"); if (muteCheckbox) { return Boolean(muteCheckbox.checked); } return localStorage.getItem(CHAT_SOUND_MUTED_STORAGE_KEY) === "1"; } /** * 同步禁音复选框和本地缓存。 * * @param {boolean} muted 是否禁音 * @returns {boolean} */ export function setSoundMuted(muted) { const normalizedMuted = Boolean(muted); localStorage.setItem(CHAT_SOUND_MUTED_STORAGE_KEY, normalizedMuted ? "1" : "0"); const muteCheckbox = document.getElementById("sound_muted"); if (muteCheckbox) { muteCheckbox.checked = normalizedMuted; } return normalizedMuted; } /** * 切换特效音效静音状态,并按需持久化到服务端偏好。 * * @param {boolean} muted 是否禁音 * @param {(muted:boolean)=>void|Promise} onChange 禁音变化回调 * @returns {boolean} */ export function toggleSoundMute(muted, onChange = undefined) { const normalizedMuted = setSoundMuted(muted); if (normalizedMuted && typeof window.EffectSounds !== "undefined") { // 开启禁音时立即停止当前音效,避免状态切换后仍继续播放。 window.EffectSounds.stop(); } if (typeof onChange === "function") { void onChange(normalizedMuted); } return normalizedMuted; } /** * 绑定禁音复选框事件,后端保存逻辑由调用方提供。 * * @param {(muted:boolean)=>void|Promise} onChange 禁音变化回调 * @returns {void} */ export function bindSoundMuteControl(onChange) { if (soundMuteEventsBound || typeof document === "undefined") { return; } soundMuteEventsBound = true; document.addEventListener("change", (event) => { if (!(event.target instanceof HTMLInputElement) || event.target.id !== "sound_muted") { return; } toggleSoundMute(event.target.checked, onChange); }); } /** * 设置浮层显示状态。 * * @param {string} elementId 元素 ID * @param {boolean} visible 是否显示 * @returns {HTMLElement|null} */ function setPanelVisible(elementId, visible) { const panel = document.getElementById(elementId); if (panel) { panel.style.display = visible ? "block" : "none"; } return panel; } /** * 关闭一组会互斥显示的聊天室浮层。 * * @param {string[]} panelIds 浮层 ID 列表 * @returns {void} */ function closePanels(panelIds) { panelIds.forEach((panelId) => setPanelVisible(panelId, false)); } /** * 关闭功能快捷菜单。 * * @returns {void} */ export function closeFeatureMenu() { setPanelVisible("feature-menu", false); } /** * 切换功能快捷菜单显示状态,并关闭其他互斥浮层。 * * @param {Event|null} event 点击事件 * @param {() => void} beforeToggle 切换前同步回调 * @returns {void} */ export function toggleFeatureMenu(event = null, beforeToggle = undefined) { event?.stopPropagation?.(); const menu = document.getElementById("feature-menu"); if (!menu) { return; } closePanels(["welcome-menu", "admin-menu", "block-menu", "daily-status-editor-overlay"]); if (typeof beforeToggle === "function") { beforeToggle(); } menu.style.display = menu.style.display === "none" ? "block" : "none"; } /** * 打开每日状态编辑器。 * * @param {() => void} beforeOpen 打开前同步回调 * @returns {void} */ export function openDailyStatusEditor(beforeOpen = undefined) { closeFeatureMenu(); if (typeof beforeOpen === "function") { beforeOpen(); } setPanelVisible("daily-status-editor-overlay", true); } /** * 关闭每日状态编辑器。 * * @returns {void} */ export function closeDailyStatusEditor() { setPanelVisible("daily-status-editor-overlay", false); } /** * 切换系统播报屏蔽菜单显示状态,并关闭其他互斥浮层。 * * @param {Event|null} event 点击事件 * @param {() => void} beforeToggle 切换前同步回调 * @returns {void} */ export function toggleBlockMenu(event = null, beforeToggle = undefined) { event?.stopPropagation?.(); const menu = document.getElementById("block-menu"); if (!menu) { return; } closePanels(["welcome-menu", "admin-menu", "feature-menu", "daily-status-editor-overlay"]); if (typeof beforeToggle === "function") { beforeToggle(); } menu.style.display = menu.style.display === "none" ? "block" : "none"; } /** * 执行功能菜单里的本地清屏动作,并在执行前关闭菜单。 * * @param {() => void} onLocalClear 本地清屏回调 * @returns {void} */ export function handleFeatureLocalClear(onLocalClear) { closeFeatureMenu(); if (typeof onLocalClear === "function") { onLocalClear(); } else if (typeof window.localClearScreen === "function") { // 默认调用聊天室清屏函数,将当前可见消息全部移除。 window.localClearScreen(); } } /** * 绑定功能菜单、每日状态编辑与系统播报屏蔽的统一事件代理。 * * @returns {void} */ export function bindBlockMenuControls() { if (blockMenuEventsBound || typeof document === "undefined") { return; } blockMenuEventsBound = true; document.addEventListener("change", (event) => { if (!(event.target instanceof HTMLInputElement)) { return; } const sender = event.target.dataset.chatBlockSender; if (!sender || typeof window.toggleBlockedSystemSender !== "function") { return; } window.toggleBlockedSystemSender(sender, event.target.checked); }); document.addEventListener("click", (event) => { if (!(event.target instanceof Element)) { return; } // 功能菜单由 Blade 动态渲染,使用 document 代理避免重复绑定新节点。 const featureMenuTrigger = event.target.closest("[data-chat-feature-menu-toggle]"); if (featureMenuTrigger) { event.preventDefault(); window.toggleFeatureMenu?.(event); return; } // 每日状态编辑器仍保留存量全局函数,这里只负责把 data-* 事件转发出去。 const dailyStatusCloseButton = event.target.closest("[data-chat-daily-status-close]"); if (dailyStatusCloseButton) { event.preventDefault(); window.closeDailyStatusEditor?.(); return; } const dailyStatusClearButton = event.target.closest("[data-chat-daily-status-clear]"); if (dailyStatusClearButton) { event.preventDefault(); window.clearDailyStatus?.(); return; } const dailyStatusItem = event.target.closest("[data-chat-daily-status-select]"); if (dailyStatusItem) { event.preventDefault(); const statusKey = dailyStatusItem.getAttribute("data-chat-daily-status-select") || ""; if (statusKey && typeof window.updateDailyStatus === "function") { window.updateDailyStatus(statusKey); } return; } if (event.target.closest("[data-chat-daily-status-editor]")) { event.stopPropagation(); return; } if (event.target.closest("[data-chat-daily-status-overlay]")) { window.closeDailyStatusEditor?.(); return; } // 快捷功能区包含本地清理、签到和跳转类动作,统一收口到当前代理入口。 const localClearButton = event.target.closest("[data-chat-feature-local-clear]"); if (localClearButton) { event.preventDefault(); window.handleFeatureLocalClear?.(); return; } const dailyStatusOpenButton = event.target.closest("[data-chat-daily-status-open]"); if (dailyStatusOpenButton) { event.preventDefault(); window.openDailyStatusEditor?.(); return; } const dailySignInButton = event.target.closest("[data-chat-feature-sign-in]"); if (dailySignInButton) { event.preventDefault(); window.closeFeatureMenu?.(); window.quickDailySignIn?.(); return; } const shortcutButton = event.target.closest("[data-chat-feature-shortcut]"); if (shortcutButton) { event.preventDefault(); const action = shortcutButton.getAttribute("data-chat-feature-shortcut") || ""; if (action && typeof window.runFeatureShortcut === "function") { window.runFeatureShortcut(action); } return; } if (event.target.closest("[data-chat-feature-menu]")) { event.stopPropagation(); return; } // 系统播报屏蔽菜单需要阻止内部点击冒泡,避免点击复选框时菜单被外层关闭。 const trigger = event.target.closest("[data-chat-block-menu-toggle]"); if (trigger) { event.preventDefault(); window.toggleBlockMenu?.(event); return; } if (event.target.closest("[data-chat-block-menu]")) { event.stopPropagation(); } }); } /** * 当前登录账号没有服务端偏好时,判断是否需要迁移旧本地偏好。 * * @param {{blocked_system_senders?:string[],sound_muted?:boolean}} serverPreferences * @param {string[]} localBlockedSenders * @param {boolean} localMuted * @returns {boolean} */ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlockedSenders, localMuted) { // 只有服务端尚无偏好时才迁移旧本地设置,避免覆盖已同步的账号配置。 const hasServerPreferences = (serverPreferences?.blocked_system_senders || []).length > 0 || Boolean(serverPreferences?.sound_muted); return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted); } /** * 根据消息内容识别其对应的屏蔽规则键。 * * @param {Record} msg 消息对象 * @returns {string|null} */ export function resolveBlockedSystemSenderKey(msg) { const fromUser = String(msg?.from_user || ""); const content = String(msg?.content || ""); if (fromUser === "钓鱼播报") { return "钓鱼播报"; } if (fromUser === "神秘箱子") { return "神秘箱子"; } if (fromUser === "星海小博士") { return "星海小博士"; } // 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以"系统传音"发送,但正文里带有"钓鱼播报"字样。 if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("钓鱼播报") || content.includes("自动钓鱼模式"))) { return "钓鱼播报"; } if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("神秘箱子")) { return "神秘箱子"; } if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("百家乐")) { return "百家乐"; } if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("赛马") || content.includes("跑马"))) { return "跑马"; } return null; } // ── 偏好持久化 ── /** * 构建当前聊天室偏好快照。 * * @returns {{blocked_system_senders:string[],sound_muted:boolean}} */ export function buildChatPreferencesPayload() { const state = window.chatState; return { blocked_system_senders: state ? Array.from(state.blockedSystemSenders) : [], sound_muted: isSoundMuted(), }; } /** * 将聊天室偏好写入本地缓存,供刷新前快速恢复与迁移兜底。 */ export function persistChatPreferencesToLocal() { const state = window.chatState; if (state) { persistBlockedSystemSenders(state.blockedSystemSenders); } setSoundMuted(isSoundMuted()); } /** * 将当前聊天室偏好保存到当前登录账号。 */ export 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); } } // ── 屏蔽 UI 同步 ── /** * 同步屏蔽菜单中的复选框状态。 */ export function syncBlockedSystemSenderCheckboxes() { const state = window.chatState; const blockedSet = state ? state.blockedSystemSenders : new Set(); const checkboxMap = { "block-sender-fishing": "钓鱼播报", "block-sender-doctor": "星海小博士", "block-sender-baccarat": "百家乐", "block-sender-horse-race": "跑马", "block-sender-mystery-box": "神秘箱子", }; Object.entries(checkboxMap).forEach(([id, sender]) => { const checkbox = document.getElementById(id); if (checkbox) { checkbox.checked = blockedSet.has(sender); } }); } /** * 批量切换当前已渲染消息的显示状态。 * * @param {string} blockKey 屏蔽规则键 * @param {boolean} hidden true = 隐藏,false = 恢复显示 */ export function setRenderedMessagesVisibilityBySender(blockKey, hidden) { const state = window.chatState; [state?.container, state?.container2].forEach(targetContainer => { if (!targetContainer) return; 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 = ""; } } }); }); if (!hidden && state?.autoScroll) { const container = state.container; const container2 = state.container2; if (container) container.scrollTop = container.scrollHeight; if (container2) container2.scrollTop = container2.scrollHeight; } } /** * 更新指定系统播报项的屏蔽状态,并在勾选后立即清理当前窗口。 * * @param {string} sender 系统播报发送者/规则键 * @param {boolean} blocked 是否屏蔽 */ export function toggleBlockedSystemSender(sender, blocked) { const state = window.chatState; if (!state) return; if (!BLOCKABLE_SYSTEM_SENDERS.includes(sender)) return; if (blocked) { state.blockedSystemSenders.add(sender); setRenderedMessagesVisibilityBySender(sender, true); } else { state.blockedSystemSenders.delete(sender); setRenderedMessagesVisibilityBySender(sender, false); } persistBlockedSystemSenders(state.blockedSystemSenders); syncBlockedSystemSenderCheckboxes(); void saveChatPreferences(); } // ── 挂载到 window:偏好持久化 ── window.saveChatPreferences = saveChatPreferences; window.syncBlockedSystemSenderCheckboxes = syncBlockedSystemSenderCheckboxes; window.setRenderedMessagesVisibilityBySender = setRenderedMessagesVisibilityBySender; window.toggleBlockedSystemSender = toggleBlockedSystemSender; window.persistChatPreferencesToLocal = persistChatPreferencesToLocal; window.buildChatPreferencesPayload = buildChatPreferencesPayload; // ── 挂载到 window:菜单/浮层控制(供 bindBlockMenuControls 事件代理调用)── window.toggleBlockMenu = toggleBlockMenu; window.toggleFeatureMenu = toggleFeatureMenu; window.closeFeatureMenu = closeFeatureMenu; window.openDailyStatusEditor = openDailyStatusEditor; window.closeDailyStatusEditor = closeDailyStatusEditor; window.handleFeatureLocalClear = handleFeatureLocalClear; // ── 每日状态 UI 同步 ── /** * 获取当前登录用户仍然有效的每日状态。 * * @returns {Object|null} */ export function getCurrentUserDailyStatus() { return normalizeDailyStatus(window.chatContext?.currentDailyStatus); } /** * 清除用户在线载荷中的状态字段,避免合并时残留旧状态。 * * @param {Record} payload 用户在线载荷 */ export 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 标准化后的状态对象 */ export function setOnlineUserDailyStatus(username, status) { const onlineUsers = window.chatState?.onlineUsers || window.onlineUsers || {}; 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; } /** * 同步状态按钮文字与图标。 */ 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(按钮、面板高亮、聊天上下文)。 */ export function syncDailyStatusUi() { const activeStatus = getCurrentUserDailyStatus(); if (window.chatContext) window.chatContext.currentDailyStatus = activeStatus; syncDailyStatusTrigger(); syncDailyStatusMenuSelection(); } // ── 每日状态更新与清除 ── /** * 向服务端发送每日状态更新请求。 * * @param {string} statusKey 状态键值 * @returns {Promise} */ export async function updateDailyStatus(statusKey) { const url = window.chatContext?.dailyStatusUpdateUrl; if (!url || !statusKey) return; const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? ""; try { const response = await fetch(url, { method: "PUT", headers: { "X-CSRF-TOKEN": csrf, "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ daily_status_key: statusKey }), }); if (!response.ok) throw new Error("update daily status failed"); const data = await response.json(); if (data?.status === "success" && window.chatContext) { window.chatContext.currentDailyStatus = data.data ?? null; } closeDailyStatusEditor(); syncDailyStatusUi(); // 让在线用户列表同步当前用户的最新状态 const username = window.chatContext?.username; if (username) { setOnlineUserDailyStatus(username, getCurrentUserDailyStatus()); } if (typeof window.renderUserList === "function") { window.renderUserList(); } } catch (error) { console.error("每日状态更新失败:", error); } } /** * 清除当前登录用户的每日状态。 * * @returns {Promise} */ export async function clearDailyStatus() { const url = window.chatContext?.dailyStatusUpdateUrl; if (!url) return; const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? ""; try { const response = await fetch(url, { method: "PUT", headers: { "X-CSRF-TOKEN": csrf, "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ daily_status_key: null }), }); if (!response.ok) throw new Error("clear daily status failed"); const data = await response.json(); if (data?.status === "success" && window.chatContext) { window.chatContext.currentDailyStatus = null; } closeDailyStatusEditor(); syncDailyStatusUi(); // 移除当前用户在线载荷中的状态字段 const username = window.chatContext?.username; if (username) { setOnlineUserDailyStatus(username, null); } if (typeof window.renderUserList === "function") { window.renderUserList(); } } catch (error) { console.error("每日状态清除失败:", error); } } // ── 挂载到 window:每日状态 ── window.getCurrentUserDailyStatus = getCurrentUserDailyStatus; window.setOnlineUserDailyStatus = setOnlineUserDailyStatus; window.syncDailyStatusUi = syncDailyStatusUi; window.updateDailyStatus = updateDailyStatus; window.clearDailyStatus = clearDailyStatus;