(() => { 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 ? ` ` : ` `; } 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 = `
${escapeHtml(it.name)}
${it.isMe ? "(我)" : ""}
`; 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 = `
待发送图片
${escapeHtml(state.pendingImageFile.name || "图片")}
将随本次发送一起发送
`; 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 = `
已登录:${state.me.username}
`; $("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 = `
游客:${state.myAnonName}
`; $("guestExitBtn").addEventListener("click", () => { state.myAnonName = null; state.accessToken = null; clearGuestSession(); stopStream(); resetChatView(); renderMe(); renderAuth(); renderOnlineList(); showGate(); }); return; } box.innerHTML = `
请在门口登录/注册/游客进入
`; $("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 = ` ${escapeHtml(msg.sender_name)} · ${escapeHtml(time)} `; 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 = `
加载中…
`; try { const data = await api("/api/admin/users"); const users = data.users || []; box.innerHTML = ""; const header = document.createElement("div"); header.className = "adminHeader"; header.innerHTML = `
用户
操作
`; box.appendChild(header); for (const u of users) { const editIcon = ` `; const trashIcon = ` `; const row = document.createElement("div"); row.className = "adminRow"; const nameClass = u.level === 3 ? "userNameAdmin" : u.level === 2 ? "userNameVerified" : u.level === 1 ? "userNameMember" : ""; row.innerHTML = `
${escapeHtml(u.username)}
`; 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 = `
失败:${escapeHtml(err.message)}
`; } } function openUserEditor(user) { const tools = $("adminToolsBox"); if (!tools) return; tools.hidden = false; tools.innerHTML = `
编辑用户:${escapeHtml(user.username)}
等级
`; 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 = `
`; 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 = `开发模式:点击这里重置密码`; } 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)); }); })();