cloudflare-chat/public/app.js

1404 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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));
});
})();