(function bootLoginPortal(global) { "use strict"; const THEME_KEY = "xiaobaiTheme"; const api = global.XiaobaiAPI; const card = document.querySelector("#loginCard"); const themeButton = document.querySelector("#loginThemeToggle"); const state = { view: "first", mode: "login", accounts: [], currentUserId: null, loading: false, confirmingId: null, error: "", username: "", password: "", passwordVisible: false, }; function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (ch) => ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] )); } function preferredTheme() { try { const stored = global.localStorage.getItem(THEME_KEY); if (stored === "dark" || stored === "light") return stored; } catch (_error) { // Fall through to the system preference. } return global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function applyTheme(theme, persist) { const normalized = theme === "dark" ? "dark" : "light"; document.documentElement.dataset.theme = normalized; document.documentElement.style.colorScheme = normalized; themeButton.textContent = normalized === "dark" ? "☀ 日间" : "🌙 夜间"; themeButton.setAttribute("aria-label", normalized === "dark" ? "切换到日间模式" : "切换到夜间模式"); if (persist) { try { global.localStorage.setItem(THEME_KEY, normalized); } catch (_error) { // Theme still applies for the current page when storage is unavailable. } } } function setError(message) { state.error = message || ""; } function formatLastUsed(value) { if (!value) return ""; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return ""; const now = new Date(); const hh = String(parsed.getHours()).padStart(2, "0"); const mm = String(parsed.getMinutes()).padStart(2, "0"); if (parsed.toDateString() === now.toDateString()) return `今天 ${hh}:${mm}`; const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); if (parsed.toDateString() === yesterday.toDateString()) return `昨天 ${hh}:${mm}`; return `${parsed.getMonth() + 1}月${parsed.getDate()}日`; } function chipsFor(account, current) { const chips = []; if (current) chips.push('当前'); if (account.role === "admin") chips.push('管理员'); if (account.membership?.subscribed) chips.push('会员'); else if (account.role !== "admin") chips.push('普通用户'); return chips.join(""); } function returnPath() { const raw = new URLSearchParams(global.location.search).get("next") || ""; if (!raw) return "/"; try { const url = new URL(raw, global.location.origin); if (url.origin !== global.location.origin) return "/"; const path = url.pathname || "/"; if (path === "/login" || path.startsWith("/login/")) return "/"; return `${path}${url.search}${url.hash}` || "/"; } catch (_error) { return "/"; } } function enterApp() { global.location.replace(returnPath()); } function formMarkup(options) { const registering = state.mode === "register"; const submitLabel = options.submitLabel || (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录"); const lead = options.lead; const hint = options.hint; const invalid = state.error ? " is-invalid" : ""; const passwordType = state.passwordVisible ? "text" : "password"; const passwordToggle = state.passwordVisible ? "隐藏" : "显示"; return [ options.back ? '' : "", `

${escapeHtml(options.title)}

`, `

${escapeHtml(lead)}

`, '
', ``, ``, "
", '
', ``, ``, ``, state.error ? `

${escapeHtml(state.error)}

` : '', ``, "
", `

${escapeHtml(hint)}

`, ].join(""); } function accountRow(account) { const current = Number(account.user_id) === Number(state.currentUserId); const confirming = Number(state.confirmingId) === Number(account.user_id); const managing = state.view === "manage"; const classes = [ "login-account-row", current ? "is-current" : "", confirming ? "is-confirming" : "", !managing ? "is-switchable" : "", ].filter(Boolean).join(" "); if (managing && confirming) { return [ `
`, `

移除「${escapeHtml(account.username)}」的本机记录?

`, '
', ``, '', "
", ].join(""); } const glyph = escapeHtml(String(account.username || "账").slice(0, 1)); const tone = Number(account.user_id || 0) % 4; const used = formatLastUsed(account.last_used_at); const action = managing ? `` : current ? '继续使用' : ""; const switchAttr = !managing && !current ? ` data-switch-id="${account.user_id}"` : ""; const resumeAttr = !managing && current ? ` data-resume-id="${account.user_id}"` : ""; return [ `
`, ``, '
', '", used ? `` : "", "
", action, "
", ].join(""); } function pickerMarkup() { const count = state.accounts.length; const managing = state.view === "manage"; return [ managing ? "" : '', `

${managing ? "管理账号记录" : "选择账号"}

`, `

${managing ? "移除只删除这台电脑上的登录记录,不会注销账号" : `这台电脑已记录 ${count} 个账号,点选即可进入,无需再次输入密码。`}

`, managing ? '

点击右侧图标移除对应记录

' : "", `
${state.accounts.map(accountRow).join("")}
`, managing ? "" : '', managing ? "" : '', state.error ? `

${escapeHtml(state.error)}

` : "", managing ? '

移除后再次登录该账号需重新输入密码

' : '

账号记录仅保存在这台电脑的浏览器中

', ].join(""); } function render() { card.classList.toggle("is-loading", state.loading); if (state.view === "first" || state.view === "add") { card.innerHTML = formMarkup({ title: state.view === "add" ? "添加账号" : "欢迎回来", lead: state.view === "add" ? "登录另一个账号,添加后可随时一键切换" : "登录后进入你的复盘空间", hint: state.view === "add" ? "添加后账号会保存在这台电脑,方便随时切换。" : "密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。", add: state.view === "add", back: state.view === "add", }); } else { card.innerHTML = pickerMarkup(); } bindCard(); if (mascots) mascots.sync(); } function bindCard() { card.querySelectorAll("[data-auth-mode]").forEach((button) => { button.addEventListener("click", () => { state.mode = button.dataset.authMode === "register" ? "register" : "login"; setError(""); render(); }); }); card.querySelectorAll("[data-login-action]").forEach((button) => { button.addEventListener("click", () => { const action = button.dataset.loginAction; if (action === "toggle-password") { togglePasswordVisible(); return; } if (action === "resume") { resumeCurrentAccount(); return; } if (action === "picker") { state.view = state.accounts.length ? "picker" : "first"; state.confirmingId = null; } else if (action === "add") { state.view = "add"; state.mode = "login"; } else if (action === "manage") { state.view = "manage"; } else if (action === "cancel-forget") { state.confirmingId = null; } setError(""); render(); }); }); card.querySelectorAll("[data-switch-id]").forEach((button) => { button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId))); }); card.querySelectorAll("[data-resume-id]").forEach((button) => { button.addEventListener("click", () => resumeCurrentAccount()); }); card.querySelectorAll("[data-confirm-id]").forEach((button) => { button.addEventListener("click", (event) => { event.stopPropagation(); state.confirmingId = Number(button.dataset.confirmId); render(); }); }); card.querySelectorAll("[data-forget-id]").forEach((button) => { button.addEventListener("click", () => forgetAccount(Number(button.dataset.forgetId))); }); const form = card.querySelector("#loginForm"); if (form) form.addEventListener("submit", submitCredentials); } async function loadAccounts() { const payload = await api.request("/api/auth/accounts"); state.accounts = payload.accounts || []; state.currentUserId = payload.current_user_id ?? null; const params = new URLSearchParams(global.location.search); if (params.get("mode") === "register") state.mode = "register"; if (params.get("notice")) setError(params.get("notice")); if (state.accounts.length) state.view = "picker"; else state.view = "first"; } async function submitCredentials(event) { event.preventDefault(); const username = document.querySelector("#loginUsername").value.trim(); const password = document.querySelector("#loginPassword").value; state.username = username; state.password = password; if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) { setError("两次输入的密码不一致。"); render(); return; } state.loading = true; setError(""); render(); try { await api.request(`/api/auth/${state.mode}`, "POST", { username, password }); await celebrateLogin(); enterApp(); } catch (error) { state.loading = false; setError(error.message || "账号操作失败"); render(); mascots.fail(); } } async function switchAccount(userId) { state.loading = true; setError(""); render(); try { await api.request("/api/auth/switch", "POST", { user_id: userId }); enterApp(); } catch (error) { state.loading = false; setError(error.message || "该账号需重新验证"); render(); } } async function resumeCurrentAccount() { state.loading = true; setError(""); render(); try { const session = await api.request("/api/auth/me"); const sessionUserId = session.user?.id; const matches = Boolean(session.authenticated) && ( !state.currentUserId || Number(sessionUserId) === Number(state.currentUserId) ); if (!matches) { throw new Error("当前会话已失效,请重新登录"); } enterApp(); } catch (error) { state.loading = false; setError(error.message || "当前会话已失效,请重新登录"); render(); } } async function forgetAccount(userId) { try { await api.request("/api/auth/forget", "POST", { user_id: userId }); state.accounts = state.accounts.filter((item) => Number(item.user_id) !== Number(userId)); state.confirmingId = null; if (!state.accounts.length) state.view = "first"; setError(""); render(); } catch (error) { setError(error.message || "移除失败"); render(); } } function togglePasswordVisible() { state.passwordVisible = !state.passwordVisible; const input = document.querySelector("#loginPassword"); const button = document.querySelector(".login-password-toggle"); if (input) { input.type = state.passwordVisible ? "text" : "password"; input.focus(); } if (button) { button.textContent = state.passwordVisible ? "隐藏" : "显示"; button.setAttribute("aria-pressed", state.passwordVisible ? "true" : "false"); button.setAttribute("aria-label", state.passwordVisible ? "隐藏密码" : "显示密码"); } mascots.sync(); } function reducedMotion() { return Boolean(global.matchMedia && global.matchMedia("(prefers-reduced-motion: reduce)").matches); } function finePointer() { return Boolean(global.matchMedia && global.matchMedia("(pointer: fine)").matches); } function wait(ms) { return new Promise((resolve) => global.setTimeout(resolve, ms)); } async function celebrateLogin() { mascots.succeed(); if (!reducedMotion()) await wait(720); } const mascots = (() => { const root = document.querySelector("#loginMascots"); const red = root && root.querySelector(".login-mascot.is-red"); const green = root && root.querySelector(".login-mascot.is-green"); const motion = { pupilX: 0, pupilY: 0, targetX: 0, targetY: 0, leanRed: 0, leanGreen: 0, targetLeanRed: 0, targetLeanGreen: 0, }; let mood = "idle"; let locked = ""; let blinkTimer = 0; let failTimer = 0; let raf = 0; function setVars() { if (!red || !green) return; const pupilX = `${motion.pupilX.toFixed(2)}px`; const pupilY = `${motion.pupilY.toFixed(2)}px`; red.style.setProperty("--pupil-x", pupilX); red.style.setProperty("--pupil-y", pupilY); green.style.setProperty("--pupil-x", pupilX); green.style.setProperty("--pupil-y", pupilY); red.style.setProperty("--lean", `${motion.leanRed.toFixed(2)}deg`); green.style.setProperty("--lean", `${motion.leanGreen.toFixed(2)}deg`); } function poseFor(next) { if (next === "account" || next === "busy") { motion.targetX = 3.2; motion.targetY = 0.8; motion.targetLeanRed = 7; motion.targetLeanGreen = 5.6; return; } if (next === "password") { motion.targetX = 0; motion.targetY = 0; motion.targetLeanRed = 0; motion.targetLeanGreen = 0; return; } if (next === "fail") { motion.targetX = 0; motion.targetY = 2.8; motion.targetLeanRed = 8; motion.targetLeanGreen = 8; return; } if (next === "success") { motion.targetX = 0; motion.targetY = 0; motion.targetLeanRed = 0; motion.targetLeanGreen = 0; return; } if (!finePointer()) { motion.targetX = 2.4; motion.targetY = 0.4; motion.targetLeanRed = 4; motion.targetLeanGreen = 3.2; return; } motion.targetLeanRed = 0; motion.targetLeanGreen = 0; } function applyMood(next) { if (!root) return; const changed = next !== mood; if (changed) { mood = next; root.dataset.mood = next; poseFor(next); } else if (next !== "idle") { poseFor(next); } if (reducedMotion()) { motion.pupilX = motion.targetX; motion.pupilY = motion.targetY; motion.leanRed = motion.targetLeanRed; motion.leanGreen = motion.targetLeanGreen; setVars(); } } function focusedControl() { const active = document.activeElement; if (!active || !card.contains(active)) return ""; if (active.classList.contains("login-password-toggle")) return "loginPassword"; return active.id || ""; } function desiredMood() { if (locked === "success") return "success"; if (state.loading) return "busy"; const focused = focusedControl(); if (focused === "loginPassword" || focused === "loginPasswordConfirm") return "password"; if (focused === "loginUsername") return "account"; if (locked === "fail") return "fail"; return "idle"; } function sync() { applyMood(desiredMood()); } function succeed() { locked = "success"; applyMood("success"); } function fail() { locked = "fail"; applyMood("fail"); global.clearTimeout(failTimer); failTimer = global.setTimeout(() => { if (locked === "fail") locked = ""; sync(); }, 900); } function blink() { if (!root || reducedMotion()) return; if (mood !== "idle" && mood !== "account" && mood !== "busy") return; root.classList.remove("is-blinking"); void root.offsetWidth; root.classList.add("is-blinking"); global.setTimeout(() => root.classList.remove("is-blinking"), 160); } function scheduleBlink() { global.clearTimeout(blinkTimer); if (reducedMotion()) return; const waitMs = 4000 + Math.random() * 2000; blinkTimer = global.setTimeout(() => { blink(); scheduleBlink(); }, waitMs); } function onMouseMove(event) { if (reducedMotion() || !finePointer()) return; if (desiredMood() !== "idle") return; const rect = root.getBoundingClientRect(); const cx = rect.left + rect.width * 0.42; const cy = rect.top + rect.height * 0.42; const dx = event.clientX - cx; const dy = event.clientY - cy; const dist = Math.hypot(dx, dy) || 1; const cap = 3.5; motion.targetX = (dx / dist) * Math.min(cap, Math.abs(dx) / 90); motion.targetY = (dy / dist) * Math.min(cap, Math.abs(dy) / 90); const tilt = Math.max(-5, Math.min(5, (dx / Math.max(global.innerWidth, 1)) * 10)); motion.targetLeanRed = tilt; motion.targetLeanGreen = tilt * 0.8; } function tick() { if (!root) return; if (!reducedMotion()) { motion.pupilX += (motion.targetX - motion.pupilX) * 0.18; motion.pupilY += (motion.targetY - motion.pupilY) * 0.18; motion.leanRed += (motion.targetLeanRed - motion.leanRed) * 0.18; motion.leanGreen += (motion.targetLeanGreen - motion.leanGreen) * 0.18; setVars(); } else { motion.pupilX = motion.targetX; motion.pupilY = motion.targetY; motion.leanRed = motion.targetLeanRed; motion.leanGreen = motion.targetLeanGreen; setVars(); } raf = global.requestAnimationFrame(tick); } function onFocusChange() { global.requestAnimationFrame(sync); } if (root) { document.addEventListener("focusin", onFocusChange); document.addEventListener("focusout", onFocusChange); global.addEventListener("mousemove", onMouseMove, { passive: true }); if (!reducedMotion()) { raf = global.requestAnimationFrame(tick); scheduleBlink(); } else { poseFor("idle"); setVars(); } sync(); } return { sync, succeed, fail }; })(); themeButton.addEventListener("click", () => { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true); }); applyTheme(preferredTheme(), false); loadAccounts() .then(render) .catch((error) => { setError(error.message || "无法连接本地服务"); state.view = "first"; render(); }); })(window);