|
|
|
@@ -0,0 +1,275 @@
|
|
|
|
|
(function (global) {
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
const ICONS = {
|
|
|
|
|
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
|
|
|
|
|
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
|
|
|
|
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
|
|
|
|
"moon": '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
|
|
|
|
"bar-chart-3": '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
|
|
|
|
"wand-2": '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/>',
|
|
|
|
|
"notebook-pen": '<path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><path d="m21.38 5.63-3-3a1 1 0 0 0-1.42 0l-5.01 5.01a2 2 0 0 0-.5.85l-.84 2.87a.5.5 0 0 0 .62.62l2.87-.84a2 2 0 0 0 .85-.5z"/>',
|
|
|
|
|
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
|
|
|
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
|
|
|
|
"inbox": '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function icon(name, size) {
|
|
|
|
|
const body = ICONS[name] || "";
|
|
|
|
|
return '<svg width="' + (size || 22) + '" height="' + (size || 22) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 '<button class="m-header-btn" type="button" data-theme-toggle aria-label="' + label + '" aria-pressed="' + (dark ? "true" : "false") + '">' + icon(dark ? "sun" : "moon") + "</button>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function placeholderHtml(title, subtitle) {
|
|
|
|
|
return '<div class="m-placeholder">' +
|
|
|
|
|
'<span class="m-placeholder-icon">' + icon("inbox", 26) + "</span>" +
|
|
|
|
|
"<h2>" + escapeHtml(title) + "</h2>" +
|
|
|
|
|
"<p>" + escapeHtml(subtitle || "该页面将在后续批次实现,返回即可继续浏览。") + "</p>" +
|
|
|
|
|
"</div>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 '<li>' +
|
|
|
|
|
'<button class="m-entry" type="button" data-route="' + route + '">' +
|
|
|
|
|
'<span class="m-entry-icon">' + icon(entry.icon) + "</span>" +
|
|
|
|
|
'<span class="m-entry-body"><strong>' + escapeHtml(entry.title) + "</strong><small>" + escapeHtml(entry.subtitle) + "</small></span>" +
|
|
|
|
|
'<span class="m-entry-chevron">' + icon("chevron-right", 20) + "</span>" +
|
|
|
|
|
"</button></li>";
|
|
|
|
|
}).join("");
|
|
|
|
|
document.getElementById("m-view").innerHTML = '<ul class="m-entry-list">' + items + "</ul>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = [
|
|
|
|
|
'<div class="m-auth">',
|
|
|
|
|
'<div class="m-auth-brand">',
|
|
|
|
|
'<span class="m-brand-mark">复</span>',
|
|
|
|
|
'<h2>小白复盘</h2>',
|
|
|
|
|
'<p>登录后进入你的复盘空间</p>',
|
|
|
|
|
"</div>",
|
|
|
|
|
'<div class="m-auth-tabs">',
|
|
|
|
|
'<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>',
|
|
|
|
|
'<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>',
|
|
|
|
|
"</div>",
|
|
|
|
|
'<form id="m-auth-form">',
|
|
|
|
|
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
|
|
|
|
|
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
|
|
|
|
|
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
|
|
|
|
|
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
|
|
|
|
|
'<button class="m-btn-primary" id="m-auth-submit" type="submit">登录</button>',
|
|
|
|
|
"</form>",
|
|
|
|
|
"</div>"
|
|
|
|
|
].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);
|