// 聊天室偏好与每日状态工具,承接从 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(); } } /** * 绑定功能菜单、每日状态编辑与系统播报屏蔽的统一事件代理。 * * @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); }