1404 lines
45 KiB
JavaScript
1404 lines
45 KiB
JavaScript
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const state = {
|
||
me: null,
|
||
myAnonName: null,
|
||
accessToken: null,
|
||
currentRoom: null,
|
||
lastMessageId: 0,
|
||
es: null,
|
||
pendingGuestName: null,
|
||
pendingImageFile: null,
|
||
pendingImageUrl: null,
|
||
};
|
||
|
||
const GUEST_SESSION_KEY = "guest_session_v1";
|
||
const GUEST_SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||
const THEME_KEY = "theme";
|
||
|
||
function clearGuestSession() {
|
||
try {
|
||
localStorage.removeItem(GUEST_SESSION_KEY);
|
||
} catch {}
|
||
}
|
||
|
||
function getCurrentTheme() {
|
||
const t = document.documentElement?.dataset?.theme;
|
||
return t === "dark" ? "dark" : "light";
|
||
}
|
||
|
||
function setTheme(theme) {
|
||
const t = theme === "dark" ? "dark" : "light";
|
||
document.documentElement.dataset.theme = t;
|
||
try {
|
||
localStorage.setItem(THEME_KEY, t);
|
||
} catch {}
|
||
renderThemeBtn();
|
||
}
|
||
|
||
function renderThemeBtn() {
|
||
const btn = $("themeBtn");
|
||
if (!btn) return;
|
||
const isDark = getCurrentTheme() === "dark";
|
||
btn.setAttribute("aria-pressed", isDark ? "true" : "false");
|
||
btn.setAttribute("aria-label", isDark ? "切换亮色模式" : "切换黑夜模式");
|
||
btn.innerHTML = isDark
|
||
? `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="4" />
|
||
<path d="M12 2v2" />
|
||
<path d="M12 20v2" />
|
||
<path d="M4.93 4.93l1.41 1.41" />
|
||
<path d="M17.66 17.66l1.41 1.41" />
|
||
<path d="M2 12h2" />
|
||
<path d="M20 12h2" />
|
||
<path d="M4.93 19.07l1.41-1.41" />
|
||
<path d="M17.66 6.34l1.41-1.41" />
|
||
</svg>
|
||
`
|
||
: `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z" />
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
function loadGuestSession() {
|
||
let raw = null;
|
||
try {
|
||
raw = localStorage.getItem(GUEST_SESSION_KEY);
|
||
} catch {
|
||
return { exists: false, session: null };
|
||
}
|
||
|
||
if (!raw) return { exists: false, session: null };
|
||
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
const expired = typeof data?.expiresAt !== "number" || data.expiresAt <= Date.now();
|
||
if (data?.v !== 1 || expired) {
|
||
clearGuestSession();
|
||
return { exists: true, session: null };
|
||
}
|
||
const roomId = typeof data.roomId === "string" ? data.roomId : "";
|
||
const nickname = typeof data.nickname === "string" ? data.nickname.trim() : "";
|
||
const accessToken = typeof data.accessToken === "string" ? data.accessToken : null;
|
||
if (!roomId || !nickname) {
|
||
clearGuestSession();
|
||
return { exists: true, session: null };
|
||
}
|
||
return { exists: true, session: { roomId, nickname, accessToken } };
|
||
} catch {
|
||
clearGuestSession();
|
||
return { exists: true, session: null };
|
||
}
|
||
}
|
||
|
||
function saveGuestSession({ roomId, nickname, accessToken }) {
|
||
try {
|
||
localStorage.setItem(
|
||
GUEST_SESSION_KEY,
|
||
JSON.stringify({
|
||
v: 1,
|
||
roomId,
|
||
nickname,
|
||
accessToken,
|
||
expiresAt: Date.now() + GUEST_SESSION_TTL_MS,
|
||
}),
|
||
);
|
||
} catch {}
|
||
}
|
||
|
||
function randomGuestName() {
|
||
return `游客-${Math.floor(1000 + Math.random() * 9000)}`;
|
||
}
|
||
|
||
let toastTimer = null;
|
||
function toast(message, kind = "info", ms = 2200) {
|
||
const el = $("toast");
|
||
if (!el) return;
|
||
el.classList.remove("ok", "bad");
|
||
if (kind === "ok") el.classList.add("ok");
|
||
if (kind === "bad") el.classList.add("bad");
|
||
el.textContent = message;
|
||
el.hidden = false;
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
el.hidden = true;
|
||
}, ms);
|
||
}
|
||
|
||
function confirmDialog({ title, message, okText = "确定", cancelText = "取消" }) {
|
||
const overlay = $("confirm");
|
||
const titleEl = $("confirmTitle");
|
||
const msgEl = $("confirmMsg");
|
||
const okBtn = $("confirmOk");
|
||
const cancelBtn = $("confirmCancel");
|
||
if (!overlay || !titleEl || !msgEl || !okBtn || !cancelBtn) return Promise.resolve(false);
|
||
titleEl.textContent = title || "确认";
|
||
msgEl.textContent = message || "";
|
||
okBtn.textContent = okText;
|
||
cancelBtn.textContent = cancelText;
|
||
overlay.hidden = false;
|
||
|
||
return new Promise((resolve) => {
|
||
const cleanup = () => {
|
||
overlay.hidden = true;
|
||
okBtn.removeEventListener("click", onOk);
|
||
cancelBtn.removeEventListener("click", onCancel);
|
||
window.removeEventListener("keydown", onKey);
|
||
};
|
||
const onOk = () => {
|
||
cleanup();
|
||
resolve(true);
|
||
};
|
||
const onCancel = () => {
|
||
cleanup();
|
||
resolve(false);
|
||
};
|
||
const onKey = (e) => {
|
||
if (e.key === "Escape") onCancel();
|
||
};
|
||
okBtn.addEventListener("click", onOk);
|
||
cancelBtn.addEventListener("click", onCancel);
|
||
window.addEventListener("keydown", onKey);
|
||
});
|
||
}
|
||
|
||
function setFoldDefaultsOnce() {
|
||
const isSmall = window.matchMedia("(max-width: 900px)").matches;
|
||
const accountFold = $("accountFold");
|
||
if (accountFold) accountFold.open = !isSmall;
|
||
const adminFold = $("adminCard");
|
||
if (adminFold && adminFold.style.display !== "none") adminFold.open = !isSmall;
|
||
}
|
||
|
||
function setupMobileMenu() {
|
||
const menuBtn = $("menuBtn");
|
||
const menu = $("menuDropdown");
|
||
const menuClose = $("menuCloseBtn");
|
||
const menuHost = $("menuHost");
|
||
const sidebar = document.querySelector(".sidebar");
|
||
const onlineCard = $("onlineCard");
|
||
if (!menuBtn || !menu || !menuClose || !menuHost || !sidebar || !onlineCard) return;
|
||
|
||
const mq = window.matchMedia("(max-width: 900px)");
|
||
|
||
const closeMenu = () => {
|
||
menu.hidden = true;
|
||
};
|
||
|
||
const openMenu = () => {
|
||
menu.hidden = false;
|
||
setFoldDefaultsOnce();
|
||
};
|
||
|
||
const syncPlacement = () => {
|
||
if (mq.matches) {
|
||
if (onlineCard.parentElement !== menuHost) menuHost.prepend(onlineCard);
|
||
closeMenu();
|
||
} else {
|
||
if (onlineCard.parentElement !== sidebar) sidebar.prepend(onlineCard);
|
||
closeMenu();
|
||
}
|
||
};
|
||
|
||
syncPlacement();
|
||
if (mq.addEventListener) mq.addEventListener("change", syncPlacement);
|
||
else mq.addListener(syncPlacement);
|
||
|
||
menuBtn.addEventListener("click", () => {
|
||
if (menu.hidden) openMenu();
|
||
else closeMenu();
|
||
});
|
||
menuClose.addEventListener("click", closeMenu);
|
||
|
||
document.addEventListener("click", (e) => {
|
||
if (menu.hidden) return;
|
||
const target = e.target;
|
||
if (target === menuBtn || menuBtn.contains(target)) return;
|
||
if (menu.contains(target)) return;
|
||
closeMenu();
|
||
});
|
||
|
||
window.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") closeMenu();
|
||
});
|
||
}
|
||
|
||
function renderOnlineList() {
|
||
const list = $("onlineList");
|
||
const count = $("onlineCount");
|
||
if (!list || !count) return;
|
||
|
||
const items = [];
|
||
if (state.me) {
|
||
items.push({ name: state.me.username, level: state.me.level, isMe: true });
|
||
} else if (state.myAnonName) {
|
||
items.push({ name: state.myAnonName, level: 0, isMe: true });
|
||
}
|
||
|
||
count.textContent = items.length ? String(items.length) : "";
|
||
list.innerHTML = "";
|
||
|
||
if (!items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "hint";
|
||
empty.textContent = "未进入聊天室";
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
for (const it of items) {
|
||
const row = document.createElement("div");
|
||
row.className = "onlineItem";
|
||
const nameClass = it.level === 3 ? "userNameAdmin" : it.level === 2 ? "userNameVerified" : it.level === 1 ? "userNameMember" : "";
|
||
row.innerHTML = `
|
||
<span class="onlineDot" aria-hidden="true"></span>
|
||
<div class="onlineName ${nameClass}" title="${escapeHtml(it.name)}">${escapeHtml(it.name)}</div>
|
||
<div class="onlineMeTag">${it.isMe ? "(我)" : ""}</div>
|
||
`;
|
||
list.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function prepareGuestName() {
|
||
const hint = $("gateGuestNameHint");
|
||
const userInput = $("gateGuestUser");
|
||
const name = randomGuestName();
|
||
state.pendingGuestName = name;
|
||
if (hint) hint.textContent = `将以随机昵称进入:${name}`;
|
||
if (userInput) userInput.value = name;
|
||
}
|
||
|
||
function closeEmojiPanel() {
|
||
const panel = $("emojiPanel");
|
||
if (panel) panel.hidden = true;
|
||
}
|
||
|
||
function buildEmojiPanelOnce() {
|
||
const panel = $("emojiPanel");
|
||
if (!panel || panel.childElementCount) return;
|
||
|
||
const emojis = [
|
||
"😀",
|
||
"😁",
|
||
"😂",
|
||
"🤣",
|
||
"😊",
|
||
"😍",
|
||
"😭",
|
||
"😡",
|
||
"👍",
|
||
"👎",
|
||
"🙏",
|
||
"🎉",
|
||
"❤️",
|
||
"🔥",
|
||
"💯",
|
||
"🤔",
|
||
"🙃",
|
||
"😅",
|
||
"😎",
|
||
"😴",
|
||
"😱",
|
||
"👀",
|
||
"🙌",
|
||
"🥳",
|
||
"🤝",
|
||
"🤖",
|
||
"🐱",
|
||
"🐶",
|
||
"🌟",
|
||
"✅",
|
||
"❌",
|
||
];
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "emojiGrid";
|
||
for (const e of emojis) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "emojiBtn";
|
||
btn.textContent = e;
|
||
btn.setAttribute("aria-label", e);
|
||
btn.addEventListener("click", () => {
|
||
insertIntoMsgInput(e);
|
||
closeEmojiPanel();
|
||
});
|
||
grid.appendChild(btn);
|
||
}
|
||
panel.appendChild(grid);
|
||
}
|
||
|
||
function insertIntoMsgInput(text) {
|
||
const input = $("msgInput");
|
||
if (!input) return;
|
||
input.focus();
|
||
const value = input.value || "";
|
||
const start = typeof input.selectionStart === "number" ? input.selectionStart : value.length;
|
||
const end = typeof input.selectionEnd === "number" ? input.selectionEnd : value.length;
|
||
input.value = value.slice(0, start) + text + value.slice(end);
|
||
const next = start + text.length;
|
||
try {
|
||
input.setSelectionRange(next, next);
|
||
} catch {}
|
||
}
|
||
|
||
function clearPendingImage() {
|
||
if (state.pendingImageUrl) {
|
||
try {
|
||
URL.revokeObjectURL(state.pendingImageUrl);
|
||
} catch {}
|
||
}
|
||
state.pendingImageFile = null;
|
||
state.pendingImageUrl = null;
|
||
renderAttachment();
|
||
}
|
||
|
||
function setPendingImage(file) {
|
||
if (!file) return;
|
||
if (state.pendingImageUrl) {
|
||
try {
|
||
URL.revokeObjectURL(state.pendingImageUrl);
|
||
} catch {}
|
||
}
|
||
state.pendingImageFile = file;
|
||
state.pendingImageUrl = URL.createObjectURL(file);
|
||
renderAttachment();
|
||
}
|
||
|
||
function renderAttachment() {
|
||
const box = $("composerAttach");
|
||
const thumb = $("inlineThumb");
|
||
const wrap = $("msgInputWrap");
|
||
if (!box) return;
|
||
if (!state.pendingImageFile || !state.pendingImageUrl) {
|
||
box.hidden = true;
|
||
box.innerHTML = "";
|
||
if (thumb) {
|
||
thumb.hidden = true;
|
||
thumb.removeAttribute("src");
|
||
}
|
||
if (wrap) wrap.classList.remove("hasThumb");
|
||
return;
|
||
}
|
||
|
||
box.hidden = false;
|
||
box.innerHTML = `
|
||
<div class="attachChip">
|
||
<img class="attachThumb" src="${state.pendingImageUrl}" alt="待发送图片" />
|
||
<div class="attachMeta">
|
||
<div class="attachName">${escapeHtml(state.pendingImageFile.name || "图片")}</div>
|
||
<div class="attachHint">将随本次发送一起发送</div>
|
||
</div>
|
||
<button class="btn iconBtn" id="attachRemoveBtn" type="button" aria-label="移除图片">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M18 6 6 18" />
|
||
<path d="M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
if (thumb) {
|
||
thumb.src = state.pendingImageUrl;
|
||
thumb.hidden = false;
|
||
}
|
||
if (wrap) wrap.classList.add("hasThumb");
|
||
|
||
const removeBtn = $("attachRemoveBtn");
|
||
if (removeBtn) removeBtn.addEventListener("click", () => clearPendingImage());
|
||
}
|
||
|
||
function levelName(level) {
|
||
if (level === 3) return "管理员";
|
||
if (level === 2) return "认证会员";
|
||
if (level === 1) return "注册会员";
|
||
return "匿名游客";
|
||
}
|
||
|
||
function gateError(msg) {
|
||
const el = $("gateError");
|
||
if (!el) return;
|
||
el.textContent = msg || "";
|
||
el.classList.toggle("show", Boolean(msg));
|
||
}
|
||
|
||
function showGate() {
|
||
gateError("");
|
||
const loginWrapper = $("loginWrapper");
|
||
const doors = $("doorsContainer");
|
||
const chatRoom = $("chatRoom");
|
||
if (loginWrapper) {
|
||
loginWrapper.style.display = "";
|
||
loginWrapper.classList.remove("fade-out");
|
||
}
|
||
if (doors) {
|
||
doors.style.display = "";
|
||
doors.classList.remove("open");
|
||
}
|
||
if (chatRoom) chatRoom.classList.remove("active");
|
||
refreshGateLobbyHint().catch(() => {});
|
||
prepareGuestName();
|
||
}
|
||
|
||
function hideGate() {
|
||
gateError("");
|
||
closeGateModal();
|
||
const loginWrapper = $("loginWrapper");
|
||
const doors = $("doorsContainer");
|
||
const chatRoom = $("chatRoom");
|
||
if (loginWrapper) loginWrapper.classList.add("fade-out");
|
||
if (doors) doors.classList.add("open");
|
||
if (chatRoom) chatRoom.classList.add("active");
|
||
setTimeout(() => {
|
||
const w = $("loginWrapper");
|
||
const d = $("doorsContainer");
|
||
if (w) w.style.display = "none";
|
||
if (d) d.style.display = "none";
|
||
}, 1300);
|
||
}
|
||
|
||
function setGateView(view) {
|
||
const login = $("gateLoginForm");
|
||
const reg = $("gateRegisterForm");
|
||
const guest = $("gateGuestForm");
|
||
if (!login || !reg || !guest) return;
|
||
login.hidden = view !== "login";
|
||
reg.hidden = view !== "register";
|
||
guest.hidden = view !== "guest";
|
||
$("gateTabLogin").classList.toggle("btnPrimary", view === "login");
|
||
$("gateTabRegister").classList.toggle("btnPrimary", view === "register");
|
||
$("gateTabGuest").classList.toggle("btnPrimary", view === "guest");
|
||
const titles = { login: "登录", register: "注册", guest: "游客登录" };
|
||
const titleEl = $("gateModalTitle");
|
||
if (titleEl) titleEl.textContent = titles[view] || "";
|
||
$("gateModal").hidden = false;
|
||
gateError("");
|
||
if (view === "guest") prepareGuestName();
|
||
}
|
||
|
||
function closeGateModal() {
|
||
$("gateModal").hidden = true;
|
||
gateError("");
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const resp = await fetch(path, { credentials: "include", ...opts });
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || data?.error) throw new Error(data.error || `请求失败: ${resp.status}`);
|
||
return data;
|
||
}
|
||
|
||
async function refreshMe() {
|
||
const data = await api("/api/me");
|
||
state.me = data.user;
|
||
if (state.me) {
|
||
state.myAnonName = null;
|
||
clearGuestSession();
|
||
hideGate();
|
||
}
|
||
renderMe();
|
||
renderAuth();
|
||
renderOnlineList();
|
||
$("adminCard").style.display = state.me && state.me.level >= 3 ? "" : "none";
|
||
if (state.me && state.me.level >= 3) {
|
||
loadUsersAdmin().catch(() => {});
|
||
}
|
||
setFoldDefaultsOnce();
|
||
updateComposerState();
|
||
}
|
||
|
||
async function getLobby() {
|
||
const data = await api("/api/lobby");
|
||
return data.room;
|
||
}
|
||
|
||
async function refreshGateLobbyHint() {
|
||
const hint = $("gateGuestHint");
|
||
if (!hint) return;
|
||
const pass = $("gateGuestPass");
|
||
const passGroup = pass ? pass.closest(".input-group") : null;
|
||
try {
|
||
const lobby = await getLobby();
|
||
if (lobby.is_private) {
|
||
hint.textContent = "该聊天室已设置密码:游客进入需要输入密码。";
|
||
if (passGroup) passGroup.hidden = false;
|
||
if (pass) pass.disabled = false;
|
||
} else {
|
||
hint.textContent = "该聊天室未设置密码:游客可直接进入(开放聊天室仅可浏览,发言需注册登录)。";
|
||
if (passGroup) passGroup.hidden = true;
|
||
if (pass) {
|
||
pass.disabled = true;
|
||
pass.value = "";
|
||
}
|
||
}
|
||
} catch {
|
||
hint.textContent = "";
|
||
if (passGroup) passGroup.hidden = false;
|
||
if (pass) pass.disabled = false;
|
||
}
|
||
}
|
||
|
||
function stopStream() {
|
||
if (state.es) {
|
||
state.es.close();
|
||
state.es = null;
|
||
}
|
||
}
|
||
|
||
function resetChatView() {
|
||
state.currentRoom = null;
|
||
state.accessToken = null;
|
||
state.lastMessageId = 0;
|
||
$("roomTitle").textContent = "未进入聊天室";
|
||
$("roomSubtitle").textContent = "";
|
||
$("messages").innerHTML = "";
|
||
$("msgInput").disabled = true;
|
||
$("sendBtn").disabled = true;
|
||
$("emojiBtn").disabled = true;
|
||
$("imageBtn").disabled = true;
|
||
$("imageBtn").hidden = true;
|
||
$("leaveRoomBtn").disabled = true;
|
||
clearPendingImage();
|
||
closeEmojiPanel();
|
||
renderOnlineList();
|
||
}
|
||
|
||
function renderMe() {
|
||
const meBox = $("meBox");
|
||
if (!meBox) return;
|
||
if (state.me) {
|
||
meBox.textContent = `${state.me.username} · ${levelName(state.me.level)}`;
|
||
return;
|
||
}
|
||
if (state.myAnonName) {
|
||
meBox.textContent = `${state.myAnonName} · 匿名游客`;
|
||
return;
|
||
}
|
||
meBox.textContent = "未登录";
|
||
}
|
||
|
||
function renderAuth() {
|
||
const box = $("authBox");
|
||
if (!box) return;
|
||
|
||
if (state.me) {
|
||
box.innerHTML = `
|
||
<div class="stack">
|
||
<div class="hint">已登录:${state.me.username}</div>
|
||
<button class="btn" id="logoutBtn">退出登录</button>
|
||
</div>
|
||
`;
|
||
$("logoutBtn").addEventListener("click", async () => {
|
||
await api("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||
state.me = null;
|
||
state.myAnonName = null;
|
||
clearGuestSession();
|
||
stopStream();
|
||
resetChatView();
|
||
await refreshMe();
|
||
showGate();
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (state.myAnonName) {
|
||
box.innerHTML = `
|
||
<div class="stack">
|
||
<div class="hint">游客:${state.myAnonName}</div>
|
||
<button class="btn" id="guestExitBtn">退出游客</button>
|
||
</div>
|
||
`;
|
||
$("guestExitBtn").addEventListener("click", () => {
|
||
state.myAnonName = null;
|
||
state.accessToken = null;
|
||
clearGuestSession();
|
||
stopStream();
|
||
resetChatView();
|
||
renderMe();
|
||
renderAuth();
|
||
renderOnlineList();
|
||
showGate();
|
||
});
|
||
return;
|
||
}
|
||
|
||
box.innerHTML = `
|
||
<div class="stack">
|
||
<div class="hint">请在门口登录/注册/游客进入</div>
|
||
<button class="btn btnPrimary" id="openGateBtn">打开入口</button>
|
||
</div>
|
||
`;
|
||
$("openGateBtn").addEventListener("click", () => showGate());
|
||
}
|
||
|
||
function appendMessage(msg) {
|
||
const isMe =
|
||
(state.me && msg.user_id && state.me.id === msg.user_id) ||
|
||
(!state.me && state.myAnonName && msg.user_id === null && msg.sender_name === state.myAnonName);
|
||
const row = document.createElement("div");
|
||
row.className = `msgRow ${isMe ? "right" : "left"}`;
|
||
const bubble = document.createElement("div");
|
||
bubble.className = `bubble ${isMe ? "me" : ""}`;
|
||
|
||
const meta = document.createElement("div");
|
||
meta.className = "meta";
|
||
const time = new Date(msg.created_at).toLocaleString();
|
||
const lvl = typeof msg.sender_level === "number" ? msg.sender_level : 0;
|
||
const nameClass = lvl === 3 ? "userNameAdmin" : lvl === 2 ? "userNameVerified" : lvl === 1 ? "userNameMember" : "";
|
||
meta.innerHTML = `
|
||
<span class="chatName ${nameClass}">${escapeHtml(msg.sender_name)}</span>
|
||
<span class="chatMetaSep"> · </span>
|
||
<span class="chatTime">${escapeHtml(time)}</span>
|
||
`;
|
||
bubble.appendChild(meta);
|
||
|
||
if (msg.type === "image" && msg.r2_key) {
|
||
const img = document.createElement("img");
|
||
const access = state.currentRoom?.is_private ? `?accessToken=${encodeURIComponent(state.accessToken || "")}` : "";
|
||
const src = `/api/images/${encodeURIComponent(msg.r2_key)}${access}`;
|
||
img.src = src;
|
||
img.loading = "lazy";
|
||
img.addEventListener("click", () => openImageViewer(src));
|
||
bubble.appendChild(img);
|
||
} else {
|
||
const text = document.createElement("div");
|
||
text.textContent = msg.content || "";
|
||
bubble.appendChild(text);
|
||
}
|
||
|
||
row.appendChild(bubble);
|
||
$("messages").appendChild(row);
|
||
$("messages").scrollTop = $("messages").scrollHeight;
|
||
}
|
||
|
||
async function loadMessages() {
|
||
if (!state.currentRoom) return;
|
||
const q = new URLSearchParams();
|
||
q.set("after", String(state.lastMessageId || 0));
|
||
if (state.currentRoom.is_private) q.set("accessToken", state.accessToken || "");
|
||
const data = await api(`/api/rooms/${state.currentRoom.id}/messages?${q.toString()}`);
|
||
for (const msg of data.messages) {
|
||
state.lastMessageId = Math.max(state.lastMessageId, msg.id);
|
||
appendMessage(msg);
|
||
}
|
||
}
|
||
|
||
function startStream() {
|
||
if (!state.currentRoom) return;
|
||
const q = new URLSearchParams();
|
||
q.set("after", String(state.lastMessageId || 0));
|
||
if (state.currentRoom.is_private) q.set("accessToken", state.accessToken || "");
|
||
const es = new EventSource(`/api/rooms/${state.currentRoom.id}/stream?${q.toString()}`);
|
||
state.es = es;
|
||
es.addEventListener("message", (evt) => {
|
||
const msg = JSON.parse(evt.data);
|
||
if (typeof msg?.id === "number" && msg.id <= state.lastMessageId) return;
|
||
state.lastMessageId = Math.max(state.lastMessageId, msg.id);
|
||
appendMessage(msg);
|
||
});
|
||
es.onerror = () => {
|
||
// 浏览器会自动重连;这里避免刷屏
|
||
};
|
||
}
|
||
|
||
function updateComposerState() {
|
||
const hasRoom = Boolean(state.currentRoom);
|
||
const canText =
|
||
hasRoom &&
|
||
(state.currentRoom.is_private
|
||
? Boolean(state.me) || (state.currentRoom.allow_anonymous && Boolean(state.accessToken))
|
||
: Boolean(state.me));
|
||
|
||
$("msgInput").disabled = !canText;
|
||
$("sendBtn").disabled = !canText;
|
||
$("emojiBtn").disabled = !canText;
|
||
|
||
const canImage = hasRoom && state.me && state.me.level >= 2;
|
||
$("imageBtn").hidden = !canImage;
|
||
$("imageBtn").disabled = !canText;
|
||
|
||
if (hasRoom && !state.currentRoom.is_private && !state.me) {
|
||
$("msgInput").placeholder = "开放聊天室需要登录才能发言";
|
||
} else {
|
||
$("msgInput").placeholder = "输入消息…";
|
||
}
|
||
}
|
||
|
||
async function enterLobbyAsLoggedIn() {
|
||
const lobby = await getLobby();
|
||
const joined = await api(`/api/rooms/${lobby.id}/join`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({}),
|
||
});
|
||
state.accessToken = joined.accessToken || null;
|
||
state.myAnonName = null;
|
||
clearGuestSession();
|
||
await enterLobbyView(lobby, lobby.is_private ? "私密" : "开放");
|
||
}
|
||
|
||
async function enterLobbyAsGuest({ nickname, password }) {
|
||
const lobby = await getLobby();
|
||
if (lobby.is_private) {
|
||
const joined = await api(`/api/rooms/${lobby.id}/join`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ password: password || "", nickname }),
|
||
});
|
||
state.myAnonName = joined.me?.nickname || nickname;
|
||
state.accessToken = joined.accessToken || null;
|
||
if (state.accessToken) {
|
||
saveGuestSession({ roomId: lobby.id, nickname: state.myAnonName, accessToken: state.accessToken });
|
||
}
|
||
await enterLobbyView(lobby, "私密 · 游客");
|
||
return;
|
||
}
|
||
state.myAnonName = nickname;
|
||
state.accessToken = null;
|
||
clearGuestSession();
|
||
await enterLobbyView(lobby, "开放 · 游客");
|
||
}
|
||
|
||
async function enterLobbyView(lobby, subtitle) {
|
||
stopStream();
|
||
$("messages").innerHTML = "";
|
||
state.lastMessageId = 0;
|
||
state.currentRoom = lobby;
|
||
$("roomTitle").textContent = lobby.name;
|
||
$("roomSubtitle").textContent = subtitle;
|
||
$("leaveRoomBtn").disabled = false;
|
||
await loadMessages();
|
||
startStream();
|
||
updateComposerState();
|
||
hideGate();
|
||
renderMe();
|
||
renderAuth();
|
||
renderOnlineList();
|
||
clearPendingImage();
|
||
closeEmojiPanel();
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const room = state.currentRoom;
|
||
if (!room) return;
|
||
|
||
const text = ($("msgInput").value || "").trim();
|
||
const file = state.pendingImageFile;
|
||
if (!text && !file) return;
|
||
|
||
if (text) {
|
||
const payload = {
|
||
type: "text",
|
||
content: text,
|
||
accessToken: room.is_private ? state.accessToken : undefined,
|
||
};
|
||
const data = await api(`/api/rooms/${room.id}/messages`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
$("msgInput").value = "";
|
||
if (data?.message?.id && data.message.id > state.lastMessageId) {
|
||
state.lastMessageId = data.message.id;
|
||
appendMessage(data.message);
|
||
}
|
||
}
|
||
|
||
if (file) {
|
||
await uploadImageFile(file);
|
||
clearPendingImage();
|
||
}
|
||
|
||
closeEmojiPanel();
|
||
}
|
||
|
||
async function uploadImageFile(file) {
|
||
const room = state.currentRoom;
|
||
if (!room) return;
|
||
if (!state.me) throw new Error("登录后可发送图片");
|
||
if (state.me.level < 2) throw new Error("认证会员才可以发送图片");
|
||
const form = new FormData();
|
||
form.set("file", file);
|
||
if (room.is_private) form.set("accessToken", state.accessToken || "");
|
||
const data = await api(`/api/rooms/${room.id}/upload`, { method: "POST", body: form });
|
||
if (data?.message?.id && data.message.id > state.lastMessageId) {
|
||
state.lastMessageId = data.message.id;
|
||
appendMessage(data.message);
|
||
}
|
||
}
|
||
|
||
async function loadUsersAdmin() {
|
||
const box = $("adminUsersBox");
|
||
if (!box) return;
|
||
box.innerHTML = `<div class="hint">加载中…</div>`;
|
||
try {
|
||
const data = await api("/api/admin/users");
|
||
const users = data.users || [];
|
||
box.innerHTML = "";
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "adminHeader";
|
||
header.innerHTML = `<div>用户</div><div style="text-align:right">操作</div>`;
|
||
box.appendChild(header);
|
||
|
||
for (const u of users) {
|
||
const editIcon = `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 20h9"/>
|
||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||
</svg>
|
||
`;
|
||
const trashIcon = `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M3 6h18"/>
|
||
<path d="M8 6V4h8v2"/>
|
||
<path d="M19 6l-1 14H6L5 6"/>
|
||
<path d="M10 11v6"/>
|
||
<path d="M14 11v6"/>
|
||
</svg>
|
||
`;
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "adminRow";
|
||
const nameClass =
|
||
u.level === 3 ? "userNameAdmin" : u.level === 2 ? "userNameVerified" : u.level === 1 ? "userNameMember" : "";
|
||
row.innerHTML = `
|
||
<div class="adminRowName ${nameClass}" title="${escapeHtml(u.username)}">${escapeHtml(u.username)}</div>
|
||
<div class="adminRowActions">
|
||
<button class="btn iconBtn" data-action="edit" type="button" aria-label="编辑">
|
||
${editIcon}
|
||
</button>
|
||
<button class="btn iconBtn iconBtnDanger" data-action="delete" type="button" aria-label="删除">
|
||
${trashIcon}
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
row.querySelector('[data-action="edit"]').addEventListener("click", () => {
|
||
openUserEditor(u);
|
||
});
|
||
row.querySelector('[data-action="delete"]').addEventListener("click", async () => {
|
||
const ok = await confirmDialog({
|
||
title: "删除用户",
|
||
message: `确定删除用户 ${u.username}?\n历史消息会保留,但账号会被删除。`,
|
||
okText: "删除",
|
||
cancelText: "取消",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api(`/api/admin/users/${u.id}`, { method: "DELETE" });
|
||
await loadUsersAdmin();
|
||
} catch (err) {
|
||
toast(err.message, "bad");
|
||
}
|
||
});
|
||
|
||
box.appendChild(row);
|
||
}
|
||
} catch (err) {
|
||
box.innerHTML = `<div class="hint">失败:${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function openUserEditor(user) {
|
||
const tools = $("adminToolsBox");
|
||
if (!tools) return;
|
||
tools.hidden = false;
|
||
tools.innerHTML = `
|
||
<div class="stack">
|
||
<div class="hint">编辑用户:<b>${escapeHtml(user.username)}</b></div>
|
||
<div class="row">
|
||
<span class="hint">等级</span>
|
||
<select class="input" id="editUserLevel" style="width:160px">
|
||
<option value="0">匿名游客</option>
|
||
<option value="1">注册会员</option>
|
||
<option value="2">认证会员</option>
|
||
<option value="3">管理员</option>
|
||
</select>
|
||
</div>
|
||
<input class="input" id="editUserEmail" placeholder="邮箱(选填)" autocomplete="email" />
|
||
<input class="input" id="editUserQq" placeholder="QQ(选填)" autocomplete="off" />
|
||
<input class="input" id="editUserPhone" placeholder="联系电话(选填)" autocomplete="tel" />
|
||
<div class="row">
|
||
<button class="btn btnPrimary" id="editUserSaveBtn">保存</button>
|
||
<button class="btn" id="editUserCloseBtn">收起</button>
|
||
</div>
|
||
<div class="hint" id="editUserStatus"></div>
|
||
</div>
|
||
`;
|
||
|
||
const levelEl = $("editUserLevel");
|
||
const emailEl = $("editUserEmail");
|
||
const qqEl = $("editUserQq");
|
||
const phoneEl = $("editUserPhone");
|
||
const statusEl = $("editUserStatus");
|
||
|
||
levelEl.value = String(user.level);
|
||
emailEl.value = user.email || "";
|
||
qqEl.value = user.qq || "";
|
||
phoneEl.value = user.phone || "";
|
||
|
||
$("editUserCloseBtn").addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
tools.hidden = true;
|
||
tools.innerHTML = "";
|
||
});
|
||
|
||
$("editUserSaveBtn").addEventListener("click", async (e) => {
|
||
e.preventDefault();
|
||
statusEl.textContent = "保存中…";
|
||
try {
|
||
await api(`/api/admin/users/${user.id}`, {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
level: Number(levelEl.value),
|
||
email: emailEl.value,
|
||
qq: qqEl.value,
|
||
phone: phoneEl.value,
|
||
}),
|
||
});
|
||
statusEl.textContent = "已保存";
|
||
toast("已保存", "ok");
|
||
await refreshMe();
|
||
await loadUsersAdmin();
|
||
} catch (err) {
|
||
statusEl.textContent = err.message;
|
||
toast(err.message, "bad");
|
||
}
|
||
});
|
||
}
|
||
|
||
async function showLobbyPasswordAdmin() {
|
||
if (!state.me || state.me.level < 3) return toast("需要管理员权限", "bad");
|
||
const tools = $("adminToolsBox");
|
||
if (!tools) return;
|
||
tools.hidden = !tools.hidden;
|
||
if (tools.hidden) return;
|
||
|
||
tools.innerHTML = `
|
||
<div class="stack">
|
||
<div class="hint" id="lobbyPwdMode"></div>
|
||
<input id="lobbyPwdInput" class="input" placeholder="设置大厅密码(留空=取消)" type="password" autocomplete="new-password" />
|
||
<div class="row">
|
||
<button id="lobbyPwdSaveBtn" class="btn btnPrimary">保存</button>
|
||
<button id="lobbyPwdCloseBtn" class="btn">收起</button>
|
||
</div>
|
||
<div class="hint" id="lobbyPwdStatus"></div>
|
||
</div>
|
||
`;
|
||
|
||
const lobbyPwdMode = $("lobbyPwdMode");
|
||
const lobbyPwdInput = $("lobbyPwdInput");
|
||
const lobbyPwdSaveBtn = $("lobbyPwdSaveBtn");
|
||
const lobbyPwdCloseBtn = $("lobbyPwdCloseBtn");
|
||
const lobbyPwdStatus = $("lobbyPwdStatus");
|
||
|
||
const refreshLobbyPwdMode = async () => {
|
||
if (!lobbyPwdMode || !lobbyPwdStatus) return;
|
||
lobbyPwdStatus.textContent = "";
|
||
lobbyPwdStatus.classList.remove("ok", "bad");
|
||
try {
|
||
const lobby = await getLobby();
|
||
lobbyPwdMode.textContent = lobby.is_private ? "当前:大厅已设置密码(游客进入需要密码)" : "当前:大厅未设置密码";
|
||
} catch {
|
||
lobbyPwdMode.textContent = "当前:未知(加载失败)";
|
||
}
|
||
};
|
||
|
||
const saveLobbyPassword = async () => {
|
||
if (!lobbyPwdStatus || !lobbyPwdInput) return;
|
||
lobbyPwdStatus.textContent = "保存中…";
|
||
lobbyPwdStatus.classList.remove("ok", "bad");
|
||
try {
|
||
const password = (lobbyPwdInput.value || "").trim();
|
||
await api("/api/admin/lobby/password", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
|
||
lobbyPwdStatus.textContent = "已保存";
|
||
lobbyPwdStatus.classList.add("ok");
|
||
lobbyPwdInput.value = "";
|
||
toast("已保存", "ok");
|
||
|
||
await refreshLobbyPwdMode();
|
||
await refreshGateLobbyHint();
|
||
|
||
if (state.currentRoom && state.currentRoom.id === "lobby") {
|
||
const lobby = await getLobby();
|
||
state.currentRoom = lobby;
|
||
$("roomSubtitle").textContent = state.me ? (lobby.is_private ? "私密" : "开放") : $("roomSubtitle").textContent;
|
||
updateComposerState();
|
||
}
|
||
} catch (err) {
|
||
lobbyPwdStatus.textContent = err.message || "保存失败";
|
||
lobbyPwdStatus.classList.add("bad");
|
||
toast(err.message || "保存失败", "bad");
|
||
}
|
||
};
|
||
|
||
if (lobbyPwdCloseBtn) {
|
||
lobbyPwdCloseBtn.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
tools.hidden = true;
|
||
tools.innerHTML = "";
|
||
});
|
||
}
|
||
if (lobbyPwdSaveBtn) {
|
||
lobbyPwdSaveBtn.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
saveLobbyPassword().catch((e2) => toast(e2.message, "bad"));
|
||
});
|
||
}
|
||
|
||
await refreshLobbyPwdMode();
|
||
if (lobbyPwdInput) lobbyPwdInput.focus();
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function openImageViewer(src) {
|
||
const viewer = $("imgViewer");
|
||
const img = $("imgViewerImg");
|
||
if (!viewer || !img) return;
|
||
img.src = src;
|
||
viewer.hidden = false;
|
||
}
|
||
|
||
function closeImageViewer() {
|
||
const viewer = $("imgViewer");
|
||
const img = $("imgViewerImg");
|
||
if (!viewer || !img) return;
|
||
viewer.hidden = true;
|
||
img.removeAttribute("src");
|
||
}
|
||
|
||
function bindUi() {
|
||
const imgViewerBackdrop = $("imgViewerBackdrop");
|
||
const imgViewerClose = $("imgViewerClose");
|
||
if (imgViewerBackdrop) imgViewerBackdrop.addEventListener("click", () => closeImageViewer());
|
||
if (imgViewerClose) imgViewerClose.addEventListener("click", () => closeImageViewer());
|
||
window.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") closeImageViewer();
|
||
if (e.key === "Escape") closeEmojiPanel();
|
||
});
|
||
|
||
// 找回密码模态框
|
||
const forgotModal = $("forgotModal");
|
||
const forgotStep1 = $("forgotStep1");
|
||
const forgotStep2 = $("forgotStep2");
|
||
const forgotStep1Msg = $("forgotStep1Msg");
|
||
const forgotStep2Msg = $("forgotStep2Msg");
|
||
|
||
function openForgotModal() {
|
||
$("forgotUsername").value = "";
|
||
$("forgotToken").value = "";
|
||
$("forgotNewPass").value = "";
|
||
forgotStep1Msg.textContent = "";
|
||
forgotStep2Msg.textContent = "";
|
||
forgotStep1.style.display = "";
|
||
forgotStep2.style.display = "none";
|
||
forgotModal.hidden = false;
|
||
$("forgotUsername").focus();
|
||
}
|
||
function closeForgotModal() {
|
||
forgotModal.hidden = true;
|
||
}
|
||
|
||
$("forgotPasswordBtn").addEventListener("click", openForgotModal);
|
||
$("forgotCancelBtn").addEventListener("click", closeForgotModal);
|
||
|
||
$("forgotSendBtn").addEventListener("click", async () => {
|
||
const username = $("forgotUsername").value.trim();
|
||
if (!username) { forgotStep1Msg.textContent = "请输入用户名"; return; }
|
||
$("forgotSendBtn").disabled = true;
|
||
forgotStep1Msg.textContent = "发送中...";
|
||
try {
|
||
const resp = await fetch("/api/auth/request-password-reset", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ username }),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (resp.ok) {
|
||
if (data.devResetLink) {
|
||
forgotStep1Msg.innerHTML = `开发模式:<a href="${data.devResetLink}" class="link" target="_blank">点击这里重置密码</a>`;
|
||
} else {
|
||
forgotStep1Msg.textContent = data.message || "✅ 验证码已发送到邮箱,请查收";
|
||
setTimeout(() => {
|
||
forgotStep1.style.display = "none";
|
||
forgotStep2.style.display = "";
|
||
$("forgotToken").focus();
|
||
}, 1500);
|
||
}
|
||
} else {
|
||
forgotStep1Msg.textContent = data.error || "请求失败,请稍后重试";
|
||
}
|
||
} catch {
|
||
forgotStep1Msg.textContent = "网络错误,请稍后重试";
|
||
} finally {
|
||
$("forgotSendBtn").disabled = false;
|
||
}
|
||
});
|
||
|
||
$("forgotBackBtn").addEventListener("click", () => {
|
||
forgotStep2.style.display = "none";
|
||
forgotStep1.style.display = "";
|
||
forgotStep1Msg.textContent = "";
|
||
forgotStep2Msg.textContent = "";
|
||
});
|
||
|
||
$("forgotResetBtn").addEventListener("click", async () => {
|
||
const token = $("forgotToken").value.trim();
|
||
const newPassword = $("forgotNewPass").value;
|
||
if (!token || !/^\d{6}$/.test(token)) { forgotStep2Msg.textContent = "请输入 6 位数字验证码"; return; }
|
||
if (newPassword.length < 6) { forgotStep2Msg.textContent = "密码至少需要 6 位"; return; }
|
||
$("forgotResetBtn").disabled = true;
|
||
forgotStep2Msg.textContent = "重置中...";
|
||
try {
|
||
const resp = await fetch("/api/auth/reset-password", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ token, newPassword }),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (resp.ok) {
|
||
forgotStep2Msg.textContent = "✅ 密码已重置成功!";
|
||
setTimeout(closeForgotModal, 1800);
|
||
} else {
|
||
forgotStep2Msg.textContent = data.error || "重置失败,验证码可能已过期";
|
||
}
|
||
} catch {
|
||
forgotStep2Msg.textContent = "网络错误,请稍后重试";
|
||
} finally {
|
||
$("forgotResetBtn").disabled = false;
|
||
}
|
||
});
|
||
|
||
renderThemeBtn();
|
||
const themeBtn = $("themeBtn");
|
||
if (themeBtn) {
|
||
themeBtn.addEventListener("click", () => {
|
||
setTheme(getCurrentTheme() === "dark" ? "light" : "dark");
|
||
});
|
||
}
|
||
|
||
$("sendBtn").addEventListener("click", () => sendMessage().catch((e) => toast(e.message, "bad")));
|
||
$("msgInput").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage().catch((err) => toast(err.message, "bad"));
|
||
}
|
||
});
|
||
|
||
const emojiBtn = $("emojiBtn");
|
||
const emojiPanel = $("emojiPanel");
|
||
if (emojiBtn && emojiPanel) {
|
||
buildEmojiPanelOnce();
|
||
emojiBtn.addEventListener("click", () => {
|
||
if (emojiBtn.disabled) return;
|
||
emojiPanel.hidden = !emojiPanel.hidden;
|
||
});
|
||
|
||
document.addEventListener("click", (e) => {
|
||
if (emojiPanel.hidden) return;
|
||
const target = e.target;
|
||
if (target === emojiBtn || emojiBtn.contains(target)) return;
|
||
if (emojiPanel.contains(target)) return;
|
||
closeEmojiPanel();
|
||
});
|
||
}
|
||
|
||
const imageBtn = $("imageBtn");
|
||
const fileInput = $("fileInput");
|
||
if (imageBtn && fileInput) {
|
||
imageBtn.addEventListener("click", () => {
|
||
if (imageBtn.disabled || imageBtn.hidden) return;
|
||
fileInput.click();
|
||
});
|
||
fileInput.addEventListener("change", () => {
|
||
const file = fileInput.files?.[0] || null;
|
||
fileInput.value = "";
|
||
if (!file) return;
|
||
setPendingImage(file);
|
||
});
|
||
}
|
||
$("leaveRoomBtn").addEventListener("click", () => {
|
||
(async () => {
|
||
if (state.me) {
|
||
const ok = await confirmDialog({
|
||
title: "离开聊天室",
|
||
message: "当前版本只有一个聊天室:离开将同时退出登录。确定退出吗?",
|
||
okText: "退出登录",
|
||
cancelText: "取消",
|
||
});
|
||
if (!ok) return;
|
||
await api("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||
state.me = null;
|
||
clearGuestSession();
|
||
stopStream();
|
||
resetChatView();
|
||
await refreshMe();
|
||
showGate();
|
||
return;
|
||
}
|
||
|
||
if (state.myAnonName) {
|
||
const ok = await confirmDialog({
|
||
title: "离开聊天室",
|
||
message: "离开将同时退出游客身份。确定退出吗?",
|
||
okText: "退出游客",
|
||
cancelText: "取消",
|
||
});
|
||
if (!ok) return;
|
||
state.myAnonName = null;
|
||
state.accessToken = null;
|
||
clearGuestSession();
|
||
stopStream();
|
||
resetChatView();
|
||
renderMe();
|
||
renderAuth();
|
||
renderOnlineList();
|
||
showGate();
|
||
return;
|
||
}
|
||
|
||
stopStream();
|
||
resetChatView();
|
||
showGate();
|
||
})().catch((e) => toast(e.message, "bad"));
|
||
});
|
||
$("loadUsersBtn").addEventListener("click", () => loadUsersAdmin().catch((e) => toast(e.message, "bad")));
|
||
$("lobbyPwdBtn").addEventListener("click", () => showLobbyPasswordAdmin().catch((e) => toast(e.message, "bad")));
|
||
|
||
$("gateTabLogin").addEventListener("click", () => setGateView("login"));
|
||
$("gateTabRegister").addEventListener("click", () => setGateView("register"));
|
||
$("gateTabGuest").addEventListener("click", () => setGateView("guest"));
|
||
|
||
$("gateModalClose").addEventListener("click", closeGateModal);
|
||
$("gateModal").addEventListener("click", (e) => {
|
||
if (e.target === $("gateModal")) closeGateModal();
|
||
});
|
||
|
||
$("gateLoginForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
gateError("");
|
||
try {
|
||
await api("/api/auth/login", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: $("gateLoginUser").value,
|
||
password: $("gateLoginPass").value,
|
||
}),
|
||
});
|
||
await refreshMe();
|
||
await enterLobbyAsLoggedIn();
|
||
} catch (err) {
|
||
gateError(err.message);
|
||
}
|
||
});
|
||
|
||
$("gateRegisterForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
gateError("");
|
||
try {
|
||
await api("/api/auth/register", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: $("gateRegUser").value,
|
||
password: $("gateRegPass").value,
|
||
email: $("gateRegEmail").value,
|
||
qq: $("gateRegQq").value,
|
||
phone: $("gateRegPhone").value,
|
||
}),
|
||
});
|
||
await refreshMe();
|
||
await enterLobbyAsLoggedIn();
|
||
} catch (err) {
|
||
gateError(err.message);
|
||
}
|
||
});
|
||
|
||
$("gateGuestForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
gateError("");
|
||
const nickname = state.pendingGuestName || randomGuestName();
|
||
const password = ($("gateGuestPass").value || "").trim();
|
||
try {
|
||
await enterLobbyAsGuest({ nickname, password });
|
||
prepareGuestName();
|
||
} catch (err) {
|
||
gateError(err.message);
|
||
}
|
||
});
|
||
|
||
setupMobileMenu();
|
||
}
|
||
|
||
async function tryRestoreGuestSession() {
|
||
const { exists, session } = loadGuestSession();
|
||
if (!exists) return null;
|
||
if (!session) return false;
|
||
|
||
try {
|
||
const lobby = await getLobby();
|
||
if (lobby.id !== session.roomId) {
|
||
clearGuestSession();
|
||
return false;
|
||
}
|
||
state.me = null;
|
||
state.myAnonName = session.nickname;
|
||
state.accessToken = lobby.is_private ? session.accessToken : null;
|
||
if (lobby.is_private && !state.accessToken) throw new Error("需要重新输入聊天室密码");
|
||
await enterLobbyView(lobby, lobby.is_private ? "私密 · 游客" : "开放 · 游客");
|
||
return true;
|
||
} catch {
|
||
clearGuestSession();
|
||
state.myAnonName = null;
|
||
state.accessToken = null;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
bindUi();
|
||
resetChatView();
|
||
await refreshMe();
|
||
if (state.me) {
|
||
await enterLobbyAsLoggedIn();
|
||
} else {
|
||
const restored = await tryRestoreGuestSession();
|
||
if (restored === true) return;
|
||
if (restored === false) toast("需要重新输入聊天室密码(已过期)", "bad", 2600);
|
||
closeGateModal();
|
||
showGate();
|
||
}
|
||
}
|
||
|
||
window.addEventListener("error", (e) => {
|
||
// 让你在“没反应”时也能看到原因
|
||
if (String(e?.filename || "").includes("bootstrap-autofill-overlay")) return;
|
||
console.error(e.error || e.message || e);
|
||
});
|
||
|
||
main().catch((err) => {
|
||
console.error(err);
|
||
alert(err instanceof Error ? err.message : String(err));
|
||
});
|
||
})();
|