diff --git a/app/frontend/index.html b/app/frontend/index.html index d4306a9..6fa118f 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -5,6 +5,21 @@ 小白复盘 + + +
+ + + + + + + + diff --git a/app/frontend/m/js/api.js b/app/frontend/m/js/api.js new file mode 100644 index 0000000..75ea210 --- /dev/null +++ b/app/frontend/m/js/api.js @@ -0,0 +1,41 @@ +(function (global) { + "use strict"; + + let csrfToken = ""; + + function setCsrfToken(token) { + csrfToken = token || ""; + } + + function requestOptions(method, body) { + const normalized = String(method || "GET").toUpperCase(); + const options = { method: normalized, headers: {} }; + if (!["GET", "HEAD", "OPTIONS"].includes(normalized) && csrfToken) { + options.headers["X-CSRF-Token"] = csrfToken; + } + if (body !== null && body !== undefined) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + return options; + } + + async function request(url, method, body) { + const response = await fetch(url, requestOptions(method, body)); + let payload = {}; + try { + payload = await response.json(); + } catch (_error) { + payload = {}; + } + if (!response.ok || payload.error) { + const error = new Error(payload.error || "请求失败"); + error.status = response.status; + error.payload = payload; + throw error; + } + return payload; + } + + global.MobileAPI = { setCsrfToken, request }; +})(window); diff --git a/app/frontend/m/js/boot.js b/app/frontend/m/js/boot.js new file mode 100644 index 0000000..d21951d --- /dev/null +++ b/app/frontend/m/js/boot.js @@ -0,0 +1,57 @@ +(function (global) { + "use strict"; + + const THEME_KEY = "xiaobaiTheme"; + + function readTheme() { + try { + return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light"; + } catch (_error) { + return "light"; + } + } + + function applyTheme(theme) { + const normalized = theme === "dark" ? "dark" : "light"; + const app = document.getElementById("m-app"); + app.dataset.theme = normalized; + app.style.colorScheme = normalized; + document.body.style.backgroundColor = normalized === "dark" ? "#141519" : "#f4f5f7"; + return normalized; + } + + function toggle() { + const next = readTheme() === "dark" ? "light" : "dark"; + try { + global.localStorage.setItem(THEME_KEY, next); + } catch (_error) { + // Theme still applies for the current page when storage is unavailable. + } + applyTheme(next); + } + + global.MobileTheme = { readTheme: readTheme, applyTheme: applyTheme, toggle: toggle }; + + async function boot() { + applyTheme(readTheme()); + let session = { authenticated: false }; + try { + session = await global.MobileSession.me(); + } catch (_error) { + session = { authenticated: false }; + } + const hash = global.location.hash || ""; + if (!session.authenticated) { + if (!/^#\/?auth/.test(hash)) global.location.replace("#/auth"); + } else if (/^#\/?auth/.test(hash)) { + global.location.replace("#/home"); + } + global.MobileRouter.init(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot, { once: true }); + } else { + boot(); + } +})(window); diff --git a/app/frontend/m/js/router.js b/app/frontend/m/js/router.js new file mode 100644 index 0000000..593b238 --- /dev/null +++ b/app/frontend/m/js/router.js @@ -0,0 +1,275 @@ +(function (global) { + "use strict"; + + const ICONS = { + "chevron-left": '', + "chevron-right": '', + "sun": '', + "moon": '', + "bar-chart-3": '', + "wand-2": '', + "notebook-pen": '', + "message-square": '', + "settings": '', + "inbox": '' + }; + + function icon(name, size) { + const body = ICONS[name] || ""; + return '"; + } + + function escapeHtml(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; + }); + } + + const stack = []; + let internalNav = 0; + let authMode = "login"; + + function currentHash() { + let hash = window.location.hash || ""; + if (!hash || hash === "#" || hash === "#/") return "#/home"; + if (hash.charAt(0) !== "#") hash = "#" + hash; + return hash; + } + + function routeOf(hash) { + const path = hash.replace(/^#\/?/, ""); + const parts = path.split("/").filter(Boolean); + return { name: parts[0] || "home", params: parts.slice(1) }; + } + + function currentRoute() { + return routeOf(stack.length ? stack[stack.length - 1] : currentHash()); + } + + function setHash(hash) { + internalNav++; + window.location.hash = hash; + } + + function navigate(hash) { + stack.push(hash); + setHash(hash); + render(); + } + + function replace(hash) { + if (stack.length) stack[stack.length - 1] = hash; + else stack.push(hash); + setHash(hash); + render(); + } + + function parentRoute(route) { + if (route.name === "hub") return "#/home"; + if (route.name === "feature") return "#/hub/" + route.params[0]; + if (route.name === "assistant") return "#/home"; + return null; + } + + function back() { + if (stack.length > 1) { + stack.pop(); + setHash(stack[stack.length - 1]); + render(); + return; + } + const parent = parentRoute(currentRoute()); + replace(parent || "#/home"); + } + + function updateHeader(options) { + document.getElementById("m-title").textContent = options.title || "小白复盘"; + document.getElementById("m-back").hidden = !options.back; + document.getElementById("m-actions").innerHTML = options.actions || ""; + } + + function themeToggleHtml() { + const dark = document.getElementById("m-app").dataset.theme === "dark"; + const label = dark ? "切换到日间模式" : "切换到夜间模式"; + return '"; + } + + function placeholderHtml(title, subtitle) { + return '
' + + '' + icon("inbox", 26) + "" + + "

" + escapeHtml(title) + "

" + + "

" + escapeHtml(subtitle || "该页面将在后续批次实现,返回即可继续浏览。") + "

" + + "
"; + } + + function findFeatureLabel(key) { + const hubs = global.MobileNav.hubs; + for (const hubKey in hubs) { + const items = hubs[hubKey].items || []; + for (const item of items) { + if (item.key === key) return item.label; + } + } + return null; + } + + function renderHome() { + updateHeader({ title: "小白复盘", back: false, actions: themeToggleHtml() }); + const items = global.MobileNav.entries.map(function (entry) { + const route = entry.key === "assistant" ? "#/assistant/chat" : "#/hub/" + entry.key; + return '
  • ' + + '
  • "; + }).join(""); + document.getElementById("m-view").innerHTML = '
      ' + items + "
    "; + } + + function renderHub(key) { + const hub = global.MobileNav.hubs[key]; + const title = hub ? hub.title : key; + updateHeader({ title: title, back: true }); + document.getElementById("m-view").innerHTML = placeholderHtml(title, "该入口的图标页将在后续批次实现。"); + } + + function renderFeature(key) { + const title = findFeatureLabel(key) || key; + updateHeader({ title: title, back: true }); + document.getElementById("m-view").innerHTML = placeholderHtml(title, "该功能页将在后续批次实现。"); + } + + function renderAssistant() { + updateHeader({ title: "复盘助手", back: true }); + document.getElementById("m-view").innerHTML = placeholderHtml("复盘助手", "聊天工作台将在后续批次实现。"); + } + + function renderAuth() { + updateHeader({ title: "小白复盘", back: false, actions: "" }); + document.getElementById("m-view").innerHTML = [ + '
    ', + '
    ', + '', + '

    小白复盘

    ', + '

    登录后进入你的复盘空间

    ', + "
    ", + '
    ', + '', + '', + "
    ", + '
    ', + '', + '', + '', + '', + '', + "
    ", + "
    " + ].join(""); + authMode = "login"; + bindAuth(); + } + + function setAuthMode(mode) { + authMode = mode === "register" ? "register" : "login"; + document.querySelectorAll("[data-auth-mode]").forEach(function (button) { + button.classList.toggle("active", button.dataset.authMode === authMode); + }); + document.getElementById("m-auth-confirm-field").hidden = authMode !== "register"; + document.getElementById("m-auth-confirm").required = authMode === "register"; + document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password"; + document.getElementById("m-auth-submit").textContent = authMode === "register" ? "注册并进入" : "登录"; + document.getElementById("m-auth-error").hidden = true; + } + + function bindAuth() { + document.querySelectorAll("[data-auth-mode]").forEach(function (button) { + button.addEventListener("click", function () { setAuthMode(button.dataset.authMode); }); + }); + document.getElementById("m-auth-form").addEventListener("submit", submitAuth); + } + + async function submitAuth(event) { + event.preventDefault(); + const username = document.getElementById("m-auth-username").value.trim(); + const password = document.getElementById("m-auth-password").value; + const errorElement = document.getElementById("m-auth-error"); + if (authMode === "register" && password !== document.getElementById("m-auth-confirm").value) { + errorElement.textContent = "两次输入的密码不一致。"; + errorElement.hidden = false; + return; + } + const submit = document.getElementById("m-auth-submit"); + submit.disabled = true; + try { + if (authMode === "register") { + await global.MobileSession.register(username, password); + } else { + await global.MobileSession.login(username, password); + } + replace("#/home"); + } catch (error) { + errorElement.textContent = error.message || "账号操作失败"; + errorElement.hidden = false; + } finally { + submit.disabled = false; + } + } + + function render() { + const route = currentRoute(); + if (route.name === "hub") renderHub(route.params[0]); + else if (route.name === "feature") renderFeature(route.params.join("/")); + else if (route.name === "assistant") renderAssistant(); + else if (route.name === "auth") renderAuth(); + else renderHome(); + } + + function bindGlobalEvents() { + document.getElementById("m-view").addEventListener("click", function (event) { + const entry = event.target.closest("[data-route]"); + if (entry) { + navigate(entry.dataset.route); + } + }); + document.getElementById("m-back").addEventListener("click", back); + document.addEventListener("click", function (event) { + const toggle = event.target.closest("[data-theme-toggle]"); + if (!toggle) return; + global.MobileTheme.toggle(); + if (currentRoute().name === "home") { + document.getElementById("m-actions").innerHTML = themeToggleHtml(); + } + }); + window.addEventListener("hashchange", function () { + if (internalNav > 0) { + internalNav--; + return; + } + const hash = currentHash(); + if (stack.length > 1 && hash === stack[stack.length - 2]) { + stack.pop(); + } else if (hash !== stack[stack.length - 1]) { + stack.push(hash); + } + render(); + }); + } + + function init() { + bindGlobalEvents(); + stack.length = 0; + stack.push(currentHash()); + render(); + } + + global.MobileRouter = { + init: init, + navigate: navigate, + replace: replace, + back: back, + render: render, + currentRoute: currentRoute + }; +})(window); diff --git a/app/frontend/m/js/session.js b/app/frontend/m/js/session.js new file mode 100644 index 0000000..6ef0cfc --- /dev/null +++ b/app/frontend/m/js/session.js @@ -0,0 +1,47 @@ +(function (global) { + "use strict"; + + const state = { user: null, csrfToken: "", authenticated: false }; + + function applySession(payload) { + state.user = payload.user || null; + state.csrfToken = payload.csrf_token || ""; + state.authenticated = Boolean(payload.authenticated); + global.MobileAPI.setCsrfToken(state.csrfToken); + return state; + } + + async function me() { + const payload = await global.MobileAPI.request("/api/auth/me"); + return applySession(payload); + } + + async function login(username, password) { + const payload = await global.MobileAPI.request("/api/auth/login", "POST", { + username: username, + password: password + }); + return applySession(payload); + } + + async function register(username, password) { + const payload = await global.MobileAPI.request("/api/auth/register", "POST", { + username: username, + password: password + }); + return applySession(payload); + } + + async function logout() { + try { + await global.MobileAPI.request("/api/auth/logout", "POST", {}); + } finally { + state.user = null; + state.csrfToken = ""; + state.authenticated = false; + global.MobileAPI.setCsrfToken(""); + } + } + + global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout }; +})(window);