239 lines
7.9 KiB
JavaScript
239 lines
7.9 KiB
JavaScript
|
|
/**
|
|||
|
|
* 聊天室心跳与存点模块:定时存点、手动存点、退出房间、掉线检测。
|
|||
|
|
* 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { escapeHtml } from "./html.js";
|
|||
|
|
import { pruneMessageContainer } from "./message-renderer.js";
|
|||
|
|
|
|||
|
|
const MAX_HEARTBEAT_FAILS = 3;
|
|||
|
|
const HEARTBEAT_INTERVAL = 60 * 1000;
|
|||
|
|
|
|||
|
|
let heartbeatInterval = null;
|
|||
|
|
let heartbeatFailCount = 0;
|
|||
|
|
let leaveRequestInFlight = false;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取 CSRF Token。
|
|||
|
|
*
|
|||
|
|
* @returns {string}
|
|||
|
|
*/
|
|||
|
|
function csrf() {
|
|||
|
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取共享状态对象。
|
|||
|
|
*
|
|||
|
|
* @returns {Object|null}
|
|||
|
|
*/
|
|||
|
|
function getState() {
|
|||
|
|
return window.chatState;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 存点功能(手动 + 自动)─────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 执行一次存点请求,向服务端同步在线状态并获取经验/金币。
|
|||
|
|
*
|
|||
|
|
* @param {boolean} silent 静默模式(true=仅心跳,不显示存点提示)
|
|||
|
|
* @returns {Promise<void>}
|
|||
|
|
*/
|
|||
|
|
async function saveExp(silent = false) {
|
|||
|
|
if (!window.chatContext?.heartbeatUrl) return;
|
|||
|
|
|
|||
|
|
const state = getState();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(window.chatContext.heartbeatUrl, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: {
|
|||
|
|
"X-CSRF-TOKEN": csrf(),
|
|||
|
|
"Accept": "application/json",
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 检测登录态失效
|
|||
|
|
if (response.status === 401 || response.status === 419) {
|
|||
|
|
await notifyExpiredLeave();
|
|||
|
|
window.chatDialog?.alert(
|
|||
|
|
"⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。",
|
|||
|
|
"连接警告",
|
|||
|
|
"#b45309"
|
|||
|
|
);
|
|||
|
|
window.location.href = "/";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (response.ok && data.status === "success") {
|
|||
|
|
heartbeatFailCount = 0;
|
|||
|
|
|
|||
|
|
const now = new Date();
|
|||
|
|
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
|||
|
|
now.getMinutes().toString().padStart(2, "0") + ":" +
|
|||
|
|
now.getSeconds().toString().padStart(2, "0");
|
|||
|
|
const d = data.data;
|
|||
|
|
const identitySummary = d.identity_summary ? `${d.identity_summary} · ` : "";
|
|||
|
|
|
|||
|
|
let levelInfo = "";
|
|||
|
|
if (d.is_max_level) {
|
|||
|
|
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb} · 已满级 ✓`;
|
|||
|
|
} else {
|
|||
|
|
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 本次获得的奖励提示
|
|||
|
|
let gainInfo = "";
|
|||
|
|
if (d.exp_gain > 0 || d.jjb_gain > 0) {
|
|||
|
|
const parts = [];
|
|||
|
|
if (d.exp_gain > 0) parts.push(`经验+${d.exp_gain}`);
|
|||
|
|
if (d.jjb_gain > 0) parts.push(`金币+${d.jjb_gain}`);
|
|||
|
|
gainInfo = ` 本次获得:${parts.join(",")}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 升级通知
|
|||
|
|
if (data.data.leveled_up) {
|
|||
|
|
const upDiv = document.createElement("div");
|
|||
|
|
upDiv.className = "msg-line";
|
|||
|
|
upDiv.innerHTML = `<span style="color: #d97706; font-weight: bold;">【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟</span><span class="msg-time">(${timeStr})</span>`;
|
|||
|
|
const container2 = state?.container2;
|
|||
|
|
if (container2) {
|
|||
|
|
container2.appendChild(upDiv);
|
|||
|
|
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 存点消息输出到包厢窗口
|
|||
|
|
if (!silent) {
|
|||
|
|
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
|
|||
|
|
if (container2) {
|
|||
|
|
const detailDiv = document.createElement("div");
|
|||
|
|
detailDiv.className = "msg-line";
|
|||
|
|
detailDiv.dataset.autosave = "1";
|
|||
|
|
detailDiv.innerHTML = `<span style="color: green;">${escapeHtml(levelInfo + gainInfo)}</span><span class="msg-time">(${timeStr})</span>`;
|
|||
|
|
|
|||
|
|
// 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知
|
|||
|
|
if (state) {
|
|||
|
|
state.lastAutosaveNode?.remove();
|
|||
|
|
state.lastAutosaveNode = detailDiv;
|
|||
|
|
}
|
|||
|
|
container2.appendChild(detailDiv);
|
|||
|
|
pruneMessageContainer(container2, window.chatState?.PRIVATE_MESSAGE_NODE_LIMIT || 300);
|
|||
|
|
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("存点失败", e);
|
|||
|
|
heartbeatFailCount++;
|
|||
|
|
|
|||
|
|
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
|
|||
|
|
window.chatDialog?.alert(
|
|||
|
|
"⚠️ 与服务器的连接已断开,请检查网络后重新登录。",
|
|||
|
|
"连接警告",
|
|||
|
|
"#b45309"
|
|||
|
|
);
|
|||
|
|
window.location.href = "/";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!silent) {
|
|||
|
|
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
|
|||
|
|
if (container2) {
|
|||
|
|
const sysDiv = document.createElement("div");
|
|||
|
|
sysDiv.className = "msg-line";
|
|||
|
|
sysDiv.innerHTML = '<span style="color: red;">【系统】存点失败,请稍后重试</span>';
|
|||
|
|
container2.appendChild(sysDiv);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 退出房间 ──
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 主动退出房间并关闭页面。
|
|||
|
|
*
|
|||
|
|
* @returns {Promise<void>}
|
|||
|
|
*/
|
|||
|
|
async function leaveRoom() {
|
|||
|
|
if (leaveRequestInFlight) return;
|
|||
|
|
leaveRequestInFlight = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await fetch(window.chatContext.leaveUrl + "?explicit=1", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: {
|
|||
|
|
"X-CSRF-TOKEN": csrf(),
|
|||
|
|
"Accept": "application/json",
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 弹出窗口直接关闭,如果不是弹出窗口则跳回首页
|
|||
|
|
window.close();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
window.location.href = "/";
|
|||
|
|
}, 500);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 通知服务端登录已过期(用于 401/419 响应时)。
|
|||
|
|
*
|
|||
|
|
* @returns {Promise<void>}
|
|||
|
|
*/
|
|||
|
|
async function notifyExpiredLeave() {
|
|||
|
|
if (leaveRequestInFlight) return;
|
|||
|
|
leaveRequestInFlight = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (!window.chatContext?.expiredLeaveUrl) return;
|
|||
|
|
|
|||
|
|
await fetch(window.chatContext.expiredLeaveUrl, {
|
|||
|
|
method: "GET",
|
|||
|
|
headers: { "Accept": "application/json" },
|
|||
|
|
credentials: "same-origin",
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 定时器管理 ──
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 启动心跳定时器(每 60 秒自动存点)。
|
|||
|
|
*/
|
|||
|
|
export function startHeartbeat() {
|
|||
|
|
stopHeartbeat(); // 防止重复启动
|
|||
|
|
|
|||
|
|
// 首次心跳延迟 10 秒,让 WebSocket 先连接
|
|||
|
|
const initialTimer = window.setTimeout(() => saveExp(true), 10000);
|
|||
|
|
const intervalTimer = window.setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
|
|||
|
|
|
|||
|
|
heartbeatInterval = { initial: initialTimer, interval: intervalTimer };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 停止心跳定时器。
|
|||
|
|
*/
|
|||
|
|
export function stopHeartbeat() {
|
|||
|
|
if (heartbeatInterval) {
|
|||
|
|
window.clearTimeout(heartbeatInterval.initial);
|
|||
|
|
window.clearInterval(heartbeatInterval.interval);
|
|||
|
|
heartbeatInterval = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 挂载到 window ──
|
|||
|
|
window.saveExp = saveExp;
|
|||
|
|
window.leaveRoom = leaveRoom;
|
|||
|
|
window.notifyExpiredLeave = notifyExpiredLeave;
|
|||
|
|
window.startHeartbeat = startHeartbeat;
|
|||
|
|
window.stopHeartbeat = stopHeartbeat;
|
|||
|
|
|
|||
|
|
export { saveExp, leaveRoom, notifyExpiredLeave, HEARTBEAT_INTERVAL, MAX_HEARTBEAT_FAILS };
|