feat: HEL-68 手机端 P0 同网址分流闸门与手机壳
在电脑端 index.html head 加极早分流闸门(?ui 覆盖优先,720px/移动 UA 判定,首屏前跳转 /m/);新建 frontend/m/ 手机壳(index.html、tokens/shell CSS、api/session/router/boot JS、nav.config),首页五入口、占位图标页/功能页、登录页、日/夜主题与栈式返回。未改电脑 DOM/样式,未碰问天。 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -5,6 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>小白复盘</title>
|
||||
<script>
|
||||
(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ui = params.get("ui");
|
||||
const path = window.location.pathname;
|
||||
const alreadyMobile = path === "/m" || path.indexOf("/m/") === 0;
|
||||
const forcedMobile = ui === "mobile";
|
||||
const forcedDesktop = ui === "desktop";
|
||||
const autoMobile = window.matchMedia && window.matchMedia("(max-width: 720px)").matches;
|
||||
const mobileUA = /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(navigator.userAgent || "");
|
||||
if (!forcedDesktop && !alreadyMobile && (forcedMobile || autoMobile || mobileUA)) {
|
||||
window.location.replace("/m/index.html" + window.location.search + window.location.hash);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
let theme = "light";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const nav = {
|
||||
entries: [
|
||||
{ key: "market", title: "行情数据", subtitle: "情绪 · 梯队 · 题材 · 龙虎榜", icon: "bar-chart-3" },
|
||||
{ key: "tools", title: "智能工具", subtitle: "智能选股 · 问师", icon: "wand-2" },
|
||||
{ key: "review", title: "我的复盘", subtitle: "复盘日志 · 交易记录", icon: "notebook-pen" },
|
||||
{ key: "assistant", title: "复盘助手", subtitle: "AI 对话复盘", icon: "message-square" },
|
||||
{ key: "system", title: "系统管理", subtitle: "外观主题 · 数据管理 · 关于", icon: "settings" }
|
||||
],
|
||||
|
||||
hubs: {
|
||||
market: {
|
||||
title: "行情数据",
|
||||
items: [
|
||||
{ key: "market/sentiment", label: "情绪周期" },
|
||||
{ key: "market/limit-up", label: "涨停池" },
|
||||
{ key: "market/broken", label: "炸板池" },
|
||||
{ key: "market/limit-down", label: "跌停池" },
|
||||
{ key: "market/yesterday", label: "昨日涨停" },
|
||||
{ key: "market/performance", label: "涨停表现" },
|
||||
{ key: "market/ladder", label: "市场天梯" },
|
||||
{ key: "market/rotation", label: "主题轮动" },
|
||||
{ key: "market/auction", label: "竞价" },
|
||||
{ key: "market/themes", label: "题材库" },
|
||||
{ key: "market/popularity", label: "人气榜" },
|
||||
{ key: "market/dragon", label: "龙虎榜" }
|
||||
]
|
||||
},
|
||||
tools: {
|
||||
title: "智能工具",
|
||||
items: [
|
||||
{ key: "tools/screener", label: "智能选股" },
|
||||
{ key: "tools/mentor", label: "问师" }
|
||||
]
|
||||
},
|
||||
review: {
|
||||
title: "我的复盘",
|
||||
items: [
|
||||
{ key: "review/journal", label: "复盘日志" },
|
||||
{ key: "review/trades", label: "交易记录" }
|
||||
]
|
||||
},
|
||||
system: {
|
||||
title: "系统管理",
|
||||
items: [
|
||||
{ key: "system/appearance", label: "外观主题" },
|
||||
{ key: "system/data", label: "数据管理" },
|
||||
{ key: "system/about", label: "关于" }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
tableDefaults: {
|
||||
frozenColumns: ["name", "code"],
|
||||
primaryColumns: [],
|
||||
scrollColumns: []
|
||||
}
|
||||
};
|
||||
|
||||
global.MobileNav = nav;
|
||||
})(window);
|
||||
@@ -0,0 +1,281 @@
|
||||
#m-app,
|
||||
#m-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
|
||||
#m-app {
|
||||
min-height: 100vh;
|
||||
background: var(--canvas);
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.m-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--mobile-header-height);
|
||||
padding: 0 4px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.m-header-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--mobile-touch-size);
|
||||
height: var(--mobile-touch-size);
|
||||
flex: 0 0 var(--mobile-touch-size);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-header-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.m-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-page-title);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m-view {
|
||||
padding: var(--mobile-page-padding);
|
||||
padding-bottom: calc(var(--mobile-page-padding) + var(--mobile-safe-bottom));
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.m-entry-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--elevation-card);
|
||||
}
|
||||
|
||||
.m-entry:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.m-entry-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
color: var(--action);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m-entry-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m-entry-body strong {
|
||||
font-size: var(--font-size-card-title);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.m-entry-body small {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.m-entry-chevron {
|
||||
display: inline-flex;
|
||||
color: var(--text-tertiary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m-placeholder {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m-placeholder-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.m-placeholder h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: var(--font-size-card-title);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-placeholder p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.m-auth {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.m-auth-brand {
|
||||
text-align: center;
|
||||
margin: 24px 0 20px;
|
||||
}
|
||||
|
||||
.m-brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: var(--action);
|
||||
color: var(--text-inverse);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.m-auth-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-auth-brand p {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.m-auth-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.m-auth-tab {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-auth-tab.active {
|
||||
background: var(--action-soft);
|
||||
border-color: var(--action);
|
||||
color: var(--action);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.m-form-field span {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.m-form-field input {
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.m-form-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.m-auth-error {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--market-up-soft);
|
||||
color: var(--market-up);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.m-btn-primary {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--action);
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#m-app {
|
||||
color-scheme: light;
|
||||
|
||||
--canvas: #f4f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f2f3f5;
|
||||
--surface-subtle: #f8f9fb;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-hover: #f2f3f5;
|
||||
--surface-selected: #eaf1fe;
|
||||
--border: #eceded;
|
||||
--border-strong: #dee0e3;
|
||||
--border-subtle: #eef0f3;
|
||||
--text-primary: #1f2329;
|
||||
--text-secondary: #646a73;
|
||||
--text-tertiary: #8f959e;
|
||||
--text-inverse: #ffffff;
|
||||
--action: #3370ff;
|
||||
--action-hover: #2b5fd9;
|
||||
--action-soft: #eaf1fe;
|
||||
--market-up: #e04536;
|
||||
--market-up-soft: #fdecea;
|
||||
--market-down: #16a34a;
|
||||
--market-down-soft: #e9f7ee;
|
||||
--warning: #b45309;
|
||||
--warning-soft: #fdf3e3;
|
||||
--chart-up: #c93f45;
|
||||
--chart-down: #087a55;
|
||||
--elevation-card: 0 1px 2px rgba(31, 35, 41, .04);
|
||||
--elevation-float: 0 12px 32px rgba(0, 0, 0, .14);
|
||||
--backdrop: rgba(17, 24, 39, .48);
|
||||
|
||||
--mobile-header-height: 52px;
|
||||
--mobile-touch-size: 44px;
|
||||
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--mobile-page-padding: 12px;
|
||||
--mobile-chart-height: 240px;
|
||||
--mobile-table-row-height: 44px;
|
||||
--mobile-table-header-height: 40px;
|
||||
--mobile-icon-cell: 78px;
|
||||
--mobile-sheet-radius: 12px;
|
||||
--mobile-min-width: 320px;
|
||||
|
||||
--font-size-aux: 11.5px;
|
||||
--font-size-caption: 12.5px;
|
||||
--font-size-label: 13px;
|
||||
--font-size-table: 13.5px;
|
||||
--font-size-body: 14px;
|
||||
--font-size-card-title: 15px;
|
||||
--font-size-page-title: 18px;
|
||||
--font-size-metric: 24px;
|
||||
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#m-app[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--canvas: #141519;
|
||||
--surface: #232529;
|
||||
--surface-muted: #2a2d33;
|
||||
--surface-subtle: #202329;
|
||||
--surface-raised: #232529;
|
||||
--surface-hover: #2a2d33;
|
||||
--surface-selected: #2b3b58;
|
||||
--border: #2b2e34;
|
||||
--border-strong: #3a3e47;
|
||||
--border-subtle: #2b2e34;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #a9adb3;
|
||||
--text-tertiary: #7c828a;
|
||||
--text-inverse: #ffffff;
|
||||
--action: #5b8def;
|
||||
--action-hover: #7ba5f5;
|
||||
--action-soft: #2b3b58;
|
||||
--market-up: #f26762;
|
||||
--market-up-soft: #3d2829;
|
||||
--market-down: #43bc8a;
|
||||
--market-down-soft: #22362c;
|
||||
--warning: #e2ad58;
|
||||
--warning-soft: #3d3220;
|
||||
--chart-up: #f06d73;
|
||||
--chart-down: #43bc8a;
|
||||
--elevation-card: 0 1px 2px rgba(0, 0, 0, .28);
|
||||
--elevation-float: 0 12px 32px rgba(0, 0, 0, .46);
|
||||
--backdrop: rgba(0, 0, 0, .66);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>小白复盘</title>
|
||||
<link rel="stylesheet" href="css/tokens.css">
|
||||
<link rel="stylesheet" href="css/shell.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="m-app" data-theme="light">
|
||||
<script>
|
||||
(() => {
|
||||
let theme = "light";
|
||||
try {
|
||||
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
|
||||
} catch (_error) {
|
||||
theme = "light";
|
||||
}
|
||||
document.getElementById("m-app").setAttribute("data-theme", theme);
|
||||
})();
|
||||
</script>
|
||||
<header id="m-header" class="m-header">
|
||||
<button id="m-back" class="m-header-btn" type="button" aria-label="返回" hidden>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<h1 id="m-title" class="m-title">小白复盘</h1>
|
||||
<div id="m-actions" class="m-actions"></div>
|
||||
</header>
|
||||
<main id="m-view" class="m-view"></main>
|
||||
</div>
|
||||
<script src="config/nav.config.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/session.js"></script>
|
||||
<script src="js/router.js"></script>
|
||||
<script src="js/boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user