2026-04-25 03:34:31 +08:00
|
|
|
// 聊天室偏好与每日状态工具,承接从 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";
|
2026-04-25 10:14:20 +08:00
|
|
|
// 白名单、localStorage key 与绑定标记共同保证偏好读取可控、事件只注册一次。
|
2026-04-25 03:39:31 +08:00
|
|
|
let soundMuteEventsBound = false;
|
2026-04-25 03:45:30 +08:00
|
|
|
let blockMenuEventsBound = false;
|
2026-04-25 03:34:31 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 规整聊天室偏好对象,过滤非法配置并补齐默认值。
|
|
|
|
|
*
|
|
|
|
|
* @param {Record<string, unknown>|null|undefined} raw
|
|
|
|
|
* @param {string[]} blockableSystemSenders
|
|
|
|
|
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
|
|
|
|
|
*/
|
|
|
|
|
export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) {
|
2026-04-25 10:17:51 +08:00
|
|
|
// 服务端或旧本地缓存可能包含已下架发送者,规整时只保留当前白名单。
|
2026-04-25 03:34:31 +08:00
|
|
|
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<string, unknown>|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(),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-25 03:38:27 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从本地缓存读取已屏蔽的系统播报发送者列表。
|
|
|
|
|
*
|
|
|
|
|
* @param {string[]} blockableSystemSenders
|
|
|
|
|
* @returns {string[]}
|
|
|
|
|
*/
|
|
|
|
|
export function loadBlockedSystemSenders(blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) {
|
|
|
|
|
try {
|
2026-04-25 10:10:47 +08:00
|
|
|
// 旧 localStorage 可能损坏或被手动篡改,读取后只保留当前允许屏蔽的发送者。
|
2026-04-25 03:38:27 +08:00
|
|
|
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<string>} 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 19:36:37 +08:00
|
|
|
/**
|
|
|
|
|
* 切换特效音效静音状态,并按需持久化到服务端偏好。
|
|
|
|
|
*
|
|
|
|
|
* @param {boolean} muted 是否禁音
|
|
|
|
|
* @param {(muted:boolean)=>void|Promise<void>} 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:39:31 +08:00
|
|
|
/**
|
|
|
|
|
* 绑定禁音复选框事件,后端保存逻辑由调用方提供。
|
|
|
|
|
*
|
|
|
|
|
* @param {(muted:boolean)=>void|Promise<void>} 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 19:36:37 +08:00
|
|
|
toggleSoundMute(event.target.checked, onChange);
|
2026-04-25 03:39:31 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 19:33:06 +08:00
|
|
|
/**
|
|
|
|
|
* 设置浮层显示状态。
|
|
|
|
|
*
|
|
|
|
|
* @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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 19:38:58 +08:00
|
|
|
/**
|
|
|
|
|
* 执行功能菜单里的本地清屏动作,并在执行前关闭菜单。
|
|
|
|
|
*
|
|
|
|
|
* @param {() => void} onLocalClear 本地清屏回调
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
export function handleFeatureLocalClear(onLocalClear) {
|
|
|
|
|
closeFeatureMenu();
|
|
|
|
|
|
|
|
|
|
if (typeof onLocalClear === "function") {
|
|
|
|
|
onLocalClear();
|
2026-04-27 09:19:49 +00:00
|
|
|
} else if (typeof window.localClearScreen === "function") {
|
|
|
|
|
// 默认调用聊天室清屏函数,将当前可见消息全部移除。
|
|
|
|
|
window.localClearScreen();
|
2026-04-25 19:38:58 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:45:30 +08:00
|
|
|
/**
|
2026-04-25 08:18:01 +08:00
|
|
|
* 绑定功能菜单、每日状态编辑与系统播报屏蔽的统一事件代理。
|
2026-04-25 03:45:30 +08:00
|
|
|
*
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
export function bindBlockMenuControls() {
|
|
|
|
|
if (blockMenuEventsBound || typeof document === "undefined") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blockMenuEventsBound = true;
|
2026-04-25 03:50:10 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-25 03:45:30 +08:00
|
|
|
document.addEventListener("click", (event) => {
|
|
|
|
|
if (!(event.target instanceof Element)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 08:18:01 +08:00
|
|
|
// 功能菜单由 Blade 动态渲染,使用 document 代理避免重复绑定新节点。
|
2026-04-25 03:53:29 +08:00
|
|
|
const featureMenuTrigger = event.target.closest("[data-chat-feature-menu-toggle]");
|
|
|
|
|
if (featureMenuTrigger) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
window.toggleFeatureMenu?.(event);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 08:18:01 +08:00
|
|
|
// 每日状态编辑器仍保留存量全局函数,这里只负责把 data-* 事件转发出去。
|
2026-04-25 03:53:29 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 08:18:01 +08:00
|
|
|
// 快捷功能区包含本地清理、签到和跳转类动作,统一收口到当前代理入口。
|
2026-04-25 03:53:29 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 08:18:01 +08:00
|
|
|
// 系统播报屏蔽菜单需要阻止内部点击冒泡,避免点击复选框时菜单被外层关闭。
|
2026-04-25 03:45:30 +08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 03:38:27 +08:00
|
|
|
/**
|
|
|
|
|
* 当前登录账号没有服务端偏好时,判断是否需要迁移旧本地偏好。
|
|
|
|
|
*
|
|
|
|
|
* @param {{blocked_system_senders?:string[],sound_muted?:boolean}} serverPreferences
|
|
|
|
|
* @param {string[]} localBlockedSenders
|
|
|
|
|
* @param {boolean} localMuted
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
export function shouldMigrateLocalChatPreferences(serverPreferences, localBlockedSenders, localMuted) {
|
2026-04-25 10:17:51 +08:00
|
|
|
// 只有服务端尚无偏好时才迁移旧本地设置,避免覆盖已同步的账号配置。
|
2026-04-25 03:38:27 +08:00
|
|
|
const hasServerPreferences = (serverPreferences?.blocked_system_senders || []).length > 0
|
|
|
|
|
|| Boolean(serverPreferences?.sound_muted);
|
|
|
|
|
|
|
|
|
|
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted);
|
|
|
|
|
}
|
2026-04-27 09:19:49 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 根据消息内容识别其对应的屏蔽规则键。
|
|
|
|
|
*
|
|
|
|
|
* @param {Record<string, unknown>} 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<string, unknown>} 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<void>}
|
|
|
|
|
*/
|
|
|
|
|
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<void>}
|
|
|
|
|
*/
|
|
|
|
|
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;
|