复
diff --git a/frontend/login/index.html b/frontend/login/index.html
new file mode 100644
index 0000000..4542933
--- /dev/null
+++ b/frontend/login/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
登录 · 小白复盘
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/login/page.js b/frontend/login/page.js
new file mode 100644
index 0000000..196b478
--- /dev/null
+++ b/frontend/login/page.js
@@ -0,0 +1,273 @@
+(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: "",
+ };
+
+ 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 membershipLabel(account) {
+ if (account.role === "admin") return account.membership?.subscribed ? "管理员 · 会员" : "管理员";
+ return account.membership?.subscribed ? "会员" : "普通用户";
+ }
+
+ function enterApp() {
+ const next = new URLSearchParams(global.location.search).get("next");
+ global.location.replace(next && next.startsWith("/") ? next : "/");
+ }
+
+ function formMarkup(options) {
+ const registering = state.mode === "register";
+ const submitLabel = options.submitLabel
+ || (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录");
+ return [
+ options.back
+ ? '
'
+ : "",
+ `
${escapeHtml(options.title)}
`,
+ `
${escapeHtml(options.lead)}
`,
+ '
',
+ ``,
+ ``,
+ "
",
+ '
",
+ '
密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。
',
+ ].join("");
+ }
+
+ function accountRow(account) {
+ const current = Number(account.user_id) === Number(state.currentUserId);
+ const confirming = Number(state.confirmingId) === Number(account.user_id);
+ const classes = `login-account-row${current ? " is-current" : ""}${confirming ? " is-confirming" : ""}`;
+ if (state.view === "manage" && confirming) {
+ return [
+ `
`,
+ `
移除「${escapeHtml(account.username)}」的本机记录?
`,
+ '
',
+ ``,
+ '',
+ "
",
+ ].join("");
+ }
+ const action = state.view === "manage"
+ ? `
`
+ : current
+ ? '
✓'
+ : `
`;
+ return [
+ `
`,
+ '
',
+ `${escapeHtml(account.username)}`,
+ `${escapeHtml(membershipLabel(account))}${current ? " · 当前" : ""}`,
+ "
",
+ action,
+ "
",
+ ].join("");
+ }
+
+ function pickerMarkup() {
+ const count = state.accounts.length;
+ const managing = state.view === "manage";
+ return [
+ `
${managing ? "管理账号记录" : "选择账号"}
`,
+ `
这台电脑已记录 ${count} 个账号,可直接进入,无需再次输入密码。
`,
+ managing
+ ? '
'
+ : "",
+ `
${state.accounts.map(accountRow).join("")}
`,
+ managing
+ ? ""
+ : '
',
+ managing
+ ? ""
+ : '
',
+ state.error ? `
${escapeHtml(state.error)}
` : "",
+ '
🔒账号记录仅保存在这台电脑的浏览器中
',
+ ].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: "登录后进入你的复盘空间",
+ add: state.view === "add",
+ back: state.view === "add",
+ });
+ } else {
+ card.innerHTML = pickerMarkup();
+ }
+ bindCard();
+ }
+
+ 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 === "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-confirm-id]").forEach((button) => {
+ button.addEventListener("click", () => {
+ 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;
+ 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 });
+ enterApp();
+ } catch (error) {
+ state.loading = false;
+ setError(error.message || "账号操作失败");
+ render();
+ }
+ }
+
+ 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 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();
+ }
+ }
+
+ 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);
diff --git a/frontend/m/css/features.css b/frontend/m/css/features.css
index 79addc8..671da68 100644
--- a/frontend/m/css/features.css
+++ b/frontend/m/css/features.css
@@ -3174,3 +3174,51 @@
border-top: 1px solid var(--border);
transform: translateY(calc(-1 * var(--m-keyboard-inset, 0px)));
}
+
+.m-sys-lead {
+ margin: 8px 0 12px;
+ font-size: var(--font-size-label);
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+.m-sys-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin: 0 0 12px;
+}
+
+.m-sys-grid div {
+ padding: 12px;
+ border-radius: 8px;
+ background: var(--surface);
+ box-shadow: var(--elevation-card);
+}
+
+.m-sys-grid span {
+ display: block;
+ font-size: var(--font-size-label);
+ color: var(--text-tertiary);
+}
+
+.m-sys-grid strong {
+ display: block;
+ margin-top: 4px;
+ font-size: var(--font-size-body);
+ color: var(--text-primary);
+}
+
+.m-sys-account-actions {
+ display: grid;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+.m-page[data-page^="system/"] .m-btn-primary {
+ margin-bottom: 8px;
+}
+
+.m-page[data-page^="system/"] .m-card {
+ margin-bottom: 12px;
+}
diff --git a/frontend/m/css/shell.css b/frontend/m/css/shell.css
index 1dab4dd..6ecf0b7 100644
--- a/frontend/m/css/shell.css
+++ b/frontend/m/css/shell.css
@@ -246,6 +246,29 @@ body {
padding-top: 8px;
}
+.m-auth-accounts {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.m-auth-account {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 48px;
+ padding: 0 14px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--surface);
+ color: var(--text-primary);
+}
+
+.m-auth-account span {
+ color: var(--action);
+ font-size: var(--font-size-label);
+}
+
.m-auth-brand {
text-align: center;
margin: 24px 0 20px;
@@ -313,7 +336,8 @@ body {
color: var(--text-secondary);
}
-.m-form-field input {
+.m-form-field input,
+.m-form-field select {
height: 44px;
padding: 0 12px;
border: 1px solid var(--border-strong);
@@ -323,11 +347,20 @@ body {
font-size: 14px;
}
-.m-form-field input:focus {
+.m-form-field input:focus,
+.m-form-field select:focus {
outline: none;
border-color: var(--action);
}
+.m-form-field input[type="checkbox"] {
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: none;
+ background: transparent;
+}
+
.m-auth-error {
margin: 0 0 12px;
padding: 10px 12px;
diff --git a/frontend/m/index.html b/frontend/m/index.html
index 63c6916..5ef084f 100644
--- a/frontend/m/index.html
+++ b/frontend/m/index.html
@@ -15,7 +15,9 @@
(() => {
let theme = "light";
try {
- theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
+ const stored = localStorage.getItem("xiaobaiTheme");
+ if (stored === "dark" || stored === "light") theme = stored;
+ else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark";
} catch (_error) {
theme = "light";
}
diff --git a/frontend/m/js/boot.js b/frontend/m/js/boot.js
index 9ecc085..8e015b2 100644
--- a/frontend/m/js/boot.js
+++ b/frontend/m/js/boot.js
@@ -5,10 +5,15 @@
function readTheme() {
try {
- return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
+ const stored = global.localStorage.getItem(THEME_KEY);
+ if (stored === "dark" || stored === "light") return stored;
} catch (_error) {
return "light";
}
+ if (global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches) {
+ return "dark";
+ }
+ return "light";
}
function applyTheme(theme) {
diff --git a/frontend/m/js/pages.js b/frontend/m/js/pages.js
index 6ca154f..8e78515 100644
--- a/frontend/m/js/pages.js
+++ b/frontend/m/js/pages.js
@@ -90,6 +90,7 @@
"calendar-check": '
',
"sticky-note": '
',
bell: '
',
+ lock: '
',
};
function icon(name, size) {
@@ -186,6 +187,11 @@
form: null,
dirty: false,
},
+ system: {
+ account: null,
+ admin: null,
+ adminTab: "market",
+ },
};
let sheetToken = 0;
@@ -3067,6 +3073,11 @@
"review/daily": setupReviewPage,
"review/notes": setupReviewPage,
"review/alerts": setupReviewPage,
+ "system/profile": setupSystemPage,
+ "system/password": setupSystemPage,
+ "system/membership": setupSystemPage,
+ "system/admin": setupSystemPage,
+ "system/members": setupSystemPage,
};
const COMPLEX_LOADERS = {
@@ -3085,6 +3096,11 @@
"review/daily": loadReview,
"review/notes": loadReview,
"review/alerts": loadReview,
+ "system/profile": loadSystem,
+ "system/password": loadSystem,
+ "system/membership": loadSystem,
+ "system/admin": loadSystem,
+ "system/members": loadSystem,
};
function isComplexPage(key) {
@@ -3450,7 +3466,7 @@
}
function showToast(message) {
- const root = document.getElementById("m-sheet-root");
+ const root = ensureSheetRoot();
if (!root) return;
let toast = document.getElementById("m-toast");
if (!toast) {
@@ -4847,6 +4863,524 @@
return '
";
}
+ /* ---------------------------------------------------------------- 系统管理(恢复桌面端已有能力,禁止再走占位页) */
+
+ function isSystemPage(key) {
+ return String(key || state.key || "").indexOf("system/") === 0;
+ }
+
+ function systemForbiddenHtml() {
+ return '
' +
+ '
' + icon("lock", 26) + "" +
+ "
仅管理员可访问
" +
+ "
系统设置和会员管理需要管理员权限。" +
+ "
";
+ }
+
+ function systemFill(html) {
+ const scroll = document.getElementById("m-scroll");
+ if (scroll) scroll.innerHTML = html;
+ }
+
+ function membershipDateLabel(value) {
+ if (!value) return "";
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
+ return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
+ }
+
+ function setupSystemPage(key) {
+ state.key = key;
+ state.requestedDate = todayString();
+ state.sort = { key: "", dir: null };
+ state.sortTable = { cols: null, reapply: null };
+ state.detail = null;
+ if (key === "system/admin") state.system.adminTab = "market";
+ document.getElementById("m-view").classList.add("m-view-feature");
+ global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
+ document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
+ }
+
+ function loadSystem() {
+ const key = state.key;
+ if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
+ systemFill(systemForbiddenHtml());
+ return;
+ }
+ if (key === "system/password") {
+ renderSystemPassword();
+ return;
+ }
+ const seq = nextSeq();
+ const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
+ global.MobileAPI.request(url).then(function (payload) {
+ if (seq !== state.seq || state.key !== key) return;
+ if (key === "system/admin" || key === "system/members") {
+ state.system.admin = payload || {};
+ renderSystemAdmin(key);
+ } else {
+ state.system.account = payload || {};
+ if (key === "system/profile") renderSystemProfile();
+ else renderSystemMembership();
+ }
+ }).catch(function (error) {
+ if (seq !== state.seq || state.key !== key) return;
+ systemFill(errorHtml(error && error.message ? error.message : "加载失败"));
+ });
+ }
+
+ function currentUsername() {
+ const user = global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
+ return user && user.username ? user.username : "当前账号";
+ }
+
+ function renderSystemProfile() {
+ const payload = state.system.account || {};
+ const birth = payload.birth_profile || {};
+ let birthDate = "";
+ let birthTime = "";
+ if (birth.birth_datetime) {
+ const parts = String(birth.birth_datetime).split("T");
+ birthDate = parts[0] || "";
+ birthTime = (parts[1] || "").slice(0, 5);
+ }
+ const gender = birth.gender || "unspecified";
+ const configured = Boolean(payload.birth_profile_configured);
+ const html =
+ '
' +
+ '
' + escapeHtml(currentUsername()) + "出生信息仅对当前账号可见并加密保存。
" +
+ '
原始信息加密保存且不在观气页回显;智能解读只使用排盘后的派生结果。
' +
+ formFieldHtml("出生日期", dateInputHtml("m-sys-birth-date", birthDate), true) +
+ formFieldHtml("出生时间", '
', true) +
+ formFieldHtml("性别", '
", false) +
+ '
资料状态:' + (configured ? "已加密保存" : "尚未设置") + "
" +
+ '
" +
+ '
' +
+ '' +
+ '' +
+ "
";
+ const page = document.querySelector(".m-page");
+ if (page) {
+ page.innerHTML = '
' + html + "
" +
+ '
';
+ }
+ }
+
+ function renderSystemPassword() {
+ const html =
+ '
";
+ const page = document.querySelector(".m-page");
+ if (page) {
+ page.innerHTML = '
' + html + "
" +
+ '
';
+ }
+ }
+
+ function renderSystemMembership() {
+ const payload = state.system.account || {};
+ const access = payload.llm_access || {};
+ const membership = access.membership || {};
+ const badge = membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户";
+ const stateLabel = membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通";
+ const remaining = membership.subscribed && membership.expires_at
+ ? number(membership.remaining_days) + " 天"
+ : (membership.is_admin || membership.subscribed ? "长期有效" : "--");
+ const detail = membership.subscribed
+ ? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效"))
+ : membership.is_admin
+ ? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
+ : "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。";
+ const quota = "会员默认每日智能分析额度 " + number(access.daily_limit) + " 次,由管理员统一设置。";
+ const usage = membership.active ? ("今日已用 " + number(access.used_today) + " 次") : "今日智能分析:--";
+ const usageSummary = membership.active ? (number(access.used_today) + " / " + number(access.daily_limit)) : "--";
+ const remainingCalls = membership.is_admin ? "不限" : membership.active ? (number(access.remaining_calls) + " 次") : "--";
+ const html =
+ '
' +
+ '
' + escapeHtml(badge) + "" + escapeHtml(detail) + "
" + escapeHtml(quota) + "
" +
+ '
' +
+ "
开通状态" + escapeHtml(stateLabel) + "
" +
+ "
剩余时长" + escapeHtml(remaining) + "
" +
+ "
今日智能分析" + escapeHtml(usageSummary) + "
" +
+ "
剩余智能分析" + escapeHtml(remainingCalls) + "
" +
+ "
" +
+ '
' + escapeHtml(usage) + "
" +
+ '
行情、搜索、自选与复盘:普通用户可用。智能选股、问师、问天、复盘助手:仅会员可用。
' +
+ "
";
+ systemFill(html);
+ }
+
+ function adminTabHtml() {
+ const tab = state.system.adminTab === "models" ? "models" : "market";
+ return '
' +
+ '' +
+ '' +
+ "
";
+ }
+
+ function renderSystemAdmin(key) {
+ if (key === "system/members") {
+ renderSystemMembers();
+ return;
+ }
+ const payload = state.system.admin || {};
+ const data = payload.data || {};
+ const ifind = data.ifind || {};
+ const llm = payload.llm || {};
+ const status = "Tushare " + (data.configured ? "已配置" : "未配置") +
+ " · iFinD " + (ifind.configured ? "已配置" : "未配置") +
+ " · " + number(data.snapshot_dates) + " 个交易日";
+ const refreshLabel = data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停";
+ const tab = state.system.adminTab === "models" ? "models" : "market";
+ const marketHtml =
+ '
' +
+ '
公共行情' + escapeHtml(status) + "
" + escapeHtml(refreshLabel) + "
" +
+ formFieldHtml("Tushare Token", '
', false) +
+ formFieldHtml("iFinD Refresh Token", '
', false) +
+ formFieldHtml("交易时段后台刷新", '
", false) +
+ '
所有用户读取同一份后台快照,页面不会随后台任务自动重绘。
' +
+ '
' +
+ '
历史数据回补
' +
+ formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) +
+ formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
+ '
' +
+ "
";
+ const modelsHtml =
+ '
' +
+ formFieldHtml("主模型", '
', false) +
+ formFieldHtml("辅助模型", '
', false) +
+ '
' + renderModelPoolHtml(llm.models || []) + "
" +
+ '
' +
+ "
";
+ const page = document.querySelector(".m-page");
+ if (!page) return;
+ const bar = tab === "models"
+ ? '
'
+ : '
';
+ page.innerHTML = adminTabHtml() + '
' + (tab === "models" ? modelsHtml : marketHtml) + "
" + bar;
+ if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
+ }
+
+ function renderModelPoolHtml(models) {
+ if (!models.length) {
+ return '
';
+ }
+ return models.map(function (item, index) {
+ return '
' +
+ "" + escapeHtml(item.name || ("模型 " + (index + 1))) + "" +
+ '' + (item.configured ? "已保存密钥" : "待配置") + "
" +
+ formFieldHtml("显示名称", '', true) +
+ formFieldHtml("API Base URL", '', true) +
+ formFieldHtml("模型标识", '', true) +
+ formFieldHtml("API Key", '', !item.configured) +
+ '' +
+ '未测试
' +
+ '' +
+ "";
+ }).join("");
+ }
+
+ function collectSystemModelPool() {
+ const models = (state.system.admin && state.system.admin.llm && state.system.admin.llm.models) || [];
+ const saved = new Map(models.map(function (item) { return [item.id, item]; }));
+ return Array.prototype.map.call(document.querySelectorAll("#m-sys-model-list [data-model-id]"), function (row) {
+ function fieldValue(name) {
+ const input = row.querySelector("[data-model-field='" + name + "']");
+ return String(input && input.value != null ? input.value : "").trim();
+ }
+ return {
+ id: row.dataset.modelId,
+ name: fieldValue("name"),
+ base_url: fieldValue("base_url"),
+ model: fieldValue("model"),
+ api_key: fieldValue("api_key"),
+ configured: Boolean(saved.get(row.dataset.modelId) && saved.get(row.dataset.modelId).configured),
+ };
+ });
+ }
+
+ function updateSystemModelRoleOptions(primaryId, fallbackId) {
+ const models = collectSystemModelPool();
+ const options = models.map(function (item) {
+ return '
";
+ }).join("");
+ const primary = document.getElementById("m-sys-primary-model");
+ const fallback = document.getElementById("m-sys-fallback-model");
+ if (!primary || !fallback) return;
+ primary.innerHTML = models.length ? options : '
';
+ fallback.innerHTML = '
' + options;
+ const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || "";
+ primary.value = keepPrimary;
+ fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
+ }
+
+ function renderSystemMembers() {
+ const payload = state.system.admin || {};
+ const membership = payload.membership || {};
+ const users = payload.users || [];
+ const userHtml = users.map(function (user) {
+ const admin = user.role === "admin";
+ const member = Boolean(user.membership_subscribed);
+ const identity = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
+ const expiry = member
+ ? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
+ : user.membership_status === "suspended"
+ ? "会员已停用"
+ : "尚未开通";
+ return '
' +
+ "" + escapeHtml(user.username) + "" +
+ '' + escapeHtml(identity) + " · 今日调用 " + number(user.used_today) + "
" +
+ '' + escapeHtml(expiry) + "
" +
+ formFieldHtml("状态", '", false) +
+ formFieldHtml("开通 / 续期时长", '', false) +
+ '' +
+ "";
+ }).join("") || '
';
+ const html =
+ '
' +
+ '
' +
+ formFieldHtml("会员每日智能分析上限", '
', false) +
+ '
' +
+ '
' +
+ userHtml +
+ "
";
+ systemFill(html);
+ }
+
+ function saveSystemBirth() {
+ const birthDate = (document.getElementById("m-sys-birth-date") || {}).value;
+ const birthTime = (document.getElementById("m-sys-birth-time") || {}).value;
+ if (!birthDate || !birthTime) {
+ showToast("请填写完整出生日期和时间");
+ return;
+ }
+ const button = document.querySelector("[data-system-save-birth]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/account/birth-profile", "POST", {
+ birth_datetime: birthDate + "T" + birthTime,
+ gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified",
+ trade_date: todayString(),
+ }).then(function () {
+ showToast("个人命理资料已保存到当前账号");
+ loadSystem();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "个人命理资料保存失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function deleteSystemBirth() {
+ openConfirmSheet("删除资料", "确定删除当前账号保存的个人命理资料吗?", {
+ danger: true,
+ confirmLabel: "删除",
+ onConfirm: function () {
+ global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () {
+ closeSheet();
+ showToast("个人命理资料已删除");
+ loadSystem();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "删除失败");
+ });
+ }
+ });
+ }
+
+ function saveSystemPassword() {
+ const current = (document.getElementById("m-sys-password-current") || {}).value || "";
+ const next = (document.getElementById("m-sys-password-new") || {}).value || "";
+ const confirm = (document.getElementById("m-sys-password-confirm") || {}).value || "";
+ const button = document.querySelector("[data-system-save-password]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/account/password", "POST", {
+ current_password: current,
+ new_password: next,
+ confirm_password: confirm,
+ }).then(function () {
+ const formIds = ["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"];
+ formIds.forEach(function (id) {
+ const input = document.getElementById(id);
+ if (input) input.value = "";
+ });
+ showToast("密码已更新");
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "密码更新失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function switchSystemAccount() {
+ global.location.assign("/login/");
+ }
+
+ function logoutSystemAccount() {
+ openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", {
+ danger: true,
+ confirmLabel: "退出",
+ onConfirm: function () {
+ global.MobileSession.logout().then(function () {
+ closeSheet();
+ global.MobileRouter.replace("#/auth");
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "退出失败");
+ });
+ }
+ });
+ }
+
+ function saveSystemMarket() {
+ const button = document.querySelector("[data-system-save-market]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/admin/settings", "POST", {
+ tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
+ ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
+ background_refresh_enabled: Boolean((document.getElementById("m-sys-bg-refresh") || {}).checked),
+ }).then(function () {
+ showToast("行情配置已保存");
+ loadSystem();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "系统配置保存失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function saveSystemModels() {
+ const button = document.querySelector("[data-system-save-models]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/admin/settings", "POST", {
+ models: collectSystemModelPool(),
+ primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
+ fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
+ }).then(function () {
+ showToast("模型池已保存");
+ loadSystem();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "模型池保存失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function addSystemModel() {
+ const models = collectSystemModelPool();
+ const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
+ models.push({ id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
+ const list = document.getElementById("m-sys-model-list");
+ if (list) list.innerHTML = renderModelPoolHtml(models);
+ updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
+ }
+
+ function deleteSystemModel(row) {
+ if (!row) return;
+ const id = row.dataset.modelId;
+ const primary = (document.getElementById("m-sys-primary-model") || {}).value;
+ const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
+ if (id === primary || id === fallback) {
+ showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
+ return;
+ }
+ const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
+ const list = document.getElementById("m-sys-model-list");
+ if (list) list.innerHTML = renderModelPoolHtml(models);
+ updateSystemModelRoleOptions(primary, fallback);
+ }
+
+ function testSystemModel(row) {
+ if (!row) return;
+ const status = row.querySelector("[data-model-test-status]");
+ const button = row.querySelector("[data-system-test-model]");
+ const profile = collectSystemModelPool().find(function (item) { return item.id === row.dataset.modelId; }) || {};
+ if (button) button.disabled = true;
+ if (status) status.textContent = "连接中";
+ global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile: profile }).then(function (payload) {
+ if (status) status.textContent = "已连通 · " + number(payload.result && payload.result.latency_ms) + " ms";
+ }).catch(function (error) {
+ if (status) status.textContent = error && error.message ? error.message : "测试失败";
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function startSystemRefresh() {
+ const button = document.querySelector("[data-system-refresh]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/admin/refresh", "POST", { trade_date: todayString() }).then(function (payload) {
+ showToast((payload && payload.message) || "后台刷新已提交");
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "后台刷新启动失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function startSystemBackfill() {
+ const button = document.querySelector("[data-system-backfill]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/backfill", "POST", {
+ start_date: (document.getElementById("m-sys-backfill-start") || {}).value,
+ end_date: (document.getElementById("m-sys-backfill-end") || {}).value,
+ }).then(function (payload) {
+ const count = payload && payload.results ? payload.results.length : 0;
+ showToast("历史回补完成,共处理 " + count + " 个工作日");
+ loadSystem();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "回补失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function saveSystemMemberLimit() {
+ const button = document.querySelector("[data-system-save-limit]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/admin/settings", "POST", {
+ member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value),
+ }).then(function () {
+ showToast("会员调用额度已保存");
+ loadSystem();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "会员调用额度保存失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
+ function saveSystemMember(card) {
+ if (!card) return;
+ const button = card.querySelector("[data-system-save-member]");
+ if (button) button.disabled = true;
+ global.MobileAPI.request("/api/admin/membership", "POST", {
+ user_id: card.dataset.adminUser,
+ status: (card.querySelector("[data-member-status]") || {}).value,
+ duration: (card.querySelector("[data-member-duration]") || {}).value,
+ }).then(function (payload) {
+ if (state.system.admin) state.system.admin.users = payload.users || [];
+ showToast("会员状态已更新");
+ renderSystemMembers();
+ }).catch(function (error) {
+ showToast(error && error.message ? error.message : "会员状态保存失败");
+ }).then(function () {
+ if (button) button.disabled = false;
+ });
+ }
+
/* ---------------------------------------------------------------- events */
function bindEvents() {
@@ -4976,6 +5510,30 @@
return;
}
+ const systemAdminTab = event.target.closest("[data-system-admin-tab]");
+ if (systemAdminTab) {
+ state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market";
+ renderSystemAdmin("system/admin");
+ return;
+ }
+ if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
+ if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
+ if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
+ if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
+ if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
+ if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
+ if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
+ if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
+ const deleteModel = event.target.closest("[data-system-delete-model]");
+ if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
+ const testModel = event.target.closest("[data-system-test-model]");
+ if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; }
+ if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
+ if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
+ if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
+ const saveMember = event.target.closest("[data-system-save-member]");
+ if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
+
// 我的复盘:新增/编辑/删除/筛选/提交等操作
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
if (reviewAddTrade) { openForm("trade"); return; }
diff --git a/frontend/m/js/router.js b/frontend/m/js/router.js
index e000191..7f359e8 100644
--- a/frontend/m/js/router.js
+++ b/frontend/m/js/router.js
@@ -250,6 +250,7 @@
'
小白复盘
',
'
登录后进入你的复盘空间
',
"
",
+ '
',
'
',
'',
'',
@@ -265,6 +266,7 @@
].join("");
authMode = "login";
bindAuth();
+ loadMobileAccounts();
}
function setAuthMode(mode) {
@@ -287,6 +289,34 @@
document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
}
+ async function loadMobileAccounts() {
+ const host = document.getElementById("m-auth-accounts");
+ if (!host || !global.MobileSession.listAccounts) return;
+ try {
+ const payload = await global.MobileSession.listAccounts();
+ const accounts = payload.accounts || [];
+ if (!accounts.length) return;
+ host.hidden = false;
+ host.innerHTML = accounts.map(function (account) {
+ return '';
+ }).join("");
+ host.querySelectorAll("[data-switch-id]").forEach(function (button) {
+ button.addEventListener("click", async function () {
+ try {
+ await global.MobileSession.switchAccount(Number(button.dataset.switchId));
+ replace(DEFAULT_HASH);
+ } catch (error) {
+ showAuthError(document.getElementById("m-auth-error"), error.message || "该账号需重新验证");
+ }
+ });
+ });
+ } catch (_error) {
+ host.hidden = true;
+ }
+ }
+
function showAuthError(element, message) {
element.textContent = message;
element.classList.remove("m-motion-fade-in");
diff --git a/frontend/m/js/session.js b/frontend/m/js/session.js
index faf6b38..dceefc7 100644
--- a/frontend/m/js/session.js
+++ b/frontend/m/js/session.js
@@ -47,5 +47,30 @@
return Boolean(state.user && state.user.role === "admin");
}
- global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout, isAdmin: isAdmin };
+ async function listAccounts() {
+ return global.MobileAPI.request("/api/auth/accounts");
+ }
+
+ async function switchAccount(userId) {
+ const payload = await global.MobileAPI.request("/api/auth/switch", "POST", {
+ user_id: userId
+ });
+ return applySession(payload);
+ }
+
+ async function forgetAccount(userId) {
+ await global.MobileAPI.request("/api/auth/forget", "POST", { user_id: userId });
+ }
+
+ global.MobileSession = {
+ state: state,
+ me: me,
+ login: login,
+ register: register,
+ logout: logout,
+ listAccounts: listAccounts,
+ switchAccount: switchAccount,
+ forgetAccount: forgetAccount,
+ isAdmin: isAdmin
+ };
})(window);
diff --git a/frontend/pages/sentiment/foundation.css b/frontend/pages/sentiment/foundation.css
index 6accf89..8300c5d 100644
--- a/frontend/pages/sentiment/foundation.css
+++ b/frontend/pages/sentiment/foundation.css
@@ -1011,9 +1011,9 @@
border-radius: 6px;
- background: var(--r2-ink);
+ background: var(--sentiment-tooltip-bg);
- color: var(--text-inverse);
+ color: var(--sentiment-tooltip-fg);
font-size: 11px;
@@ -1021,6 +1021,8 @@
}
.sentiment-chart-tooltip b {
+ color: inherit;
+
font-weight: 700;
}
@@ -1568,10 +1570,6 @@
#sentimentCycleView .sentiment-component-item {
padding: 4px 0px;
}
-
- #sentimentCycleView .sentiment-component-item small {
- display: none;
- }
}
@media (min-width: 768px) {
@@ -1764,3 +1762,72 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+
+@media (min-width: 1024px) {
+ #sentimentCycleView .redesigned-emotion-grid {
+ align-items: stretch;
+
+ height: var(--sentiment-analysis-height);
+
+ max-height: var(--sentiment-analysis-height);
+
+ overflow: hidden;
+ }
+
+ .redesigned-sentiment-view .sentiment-analysis-main,
+ .redesigned-sentiment-view .sentiment-analysis-rail {
+ align-self: stretch;
+
+ height: 100%;
+
+ min-height: 0px;
+ }
+
+ .redesigned-sentiment-view .sentiment-trend-panel {
+ display: flex;
+
+ flex: 1 1 auto;
+
+ flex-direction: column;
+
+ min-height: 0px;
+ }
+
+ .redesigned-sentiment-view .sentiment-chart-shell {
+ flex: 1 1 auto;
+
+ height: auto;
+
+ min-height: 0px;
+ }
+
+ .redesigned-sentiment-view .sentiment-chart-shell canvas {
+ height: 100%;
+ }
+
+ .redesigned-sentiment-view .sentiment-cycle-summary {
+ flex: 0 0 auto;
+ }
+
+ .redesigned-sentiment-view .sentiment-components-panel {
+ display: flex;
+
+ flex: 1 1 auto;
+
+ flex-direction: column;
+
+ min-height: 0px;
+ }
+
+ .redesigned-sentiment-view .sentiment-component-list {
+ display: flex;
+
+ flex: 1 1 auto;
+
+ flex-direction: column;
+
+ justify-content: space-evenly;
+
+ min-height: 0px;
+ }
+}
diff --git a/frontend/pages/sentiment/page.js b/frontend/pages/sentiment/page.js
index 135f5bb..d514b29 100644
--- a/frontend/pages/sentiment/page.js
+++ b/frontend/pages/sentiment/page.js
@@ -1,10 +1,39 @@
let sentimentChartAnimationFrame = null;
+let sentimentChartResizeObserver = null;
+let sentimentChartLastSize = "";
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
bind: bindSentimentEvents,
enter: ["loadSentiment"],
});
+function sentimentChartSizeKey(target) {
+ if (!target) return "";
+ const rect = target.getBoundingClientRect();
+ return `${Math.round(rect.width)}x${Math.round(rect.height)}x${window.devicePixelRatio || 1}`;
+}
+
+function observeSentimentTrendChart() {
+ const shell = document.querySelector("#sentimentCycleView .sentiment-chart-shell");
+ if (!shell) return;
+ if (!sentimentChartResizeObserver) {
+ sentimentChartResizeObserver = new ResizeObserver(() => {
+ if (sentimentChartAnimationFrame) return;
+ if (state.activeView !== "sentimentCycleView") return;
+ const rows = state.sentimentHistory?.rows;
+ if (!rows?.length) return;
+ const current = document.querySelector("#sentimentCycleView .sentiment-chart-shell");
+ const nextKey = sentimentChartSizeKey(current);
+ if (!nextKey || nextKey === sentimentChartLastSize) return;
+ drawSentimentTrendChart(rows, 1);
+ });
+ } else {
+ sentimentChartResizeObserver.disconnect();
+ }
+ sentimentChartLastSize = sentimentChartSizeKey(shell);
+ sentimentChartResizeObserver.observe(shell);
+}
+
async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
@@ -126,6 +155,7 @@ function animateSentimentTrendChart(rows) {
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
if (!motionEnabled()) {
drawSentimentTrendChart(rows, 1);
+ observeSentimentTrendChart();
return;
}
const startedAt = performance.now();
@@ -135,7 +165,10 @@ function animateSentimentTrendChart(rows) {
const progress = 1 - (1 - rawProgress) ** 3;
drawSentimentTrendChart(rows, progress);
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
- else sentimentChartAnimationFrame = null;
+ else {
+ sentimentChartAnimationFrame = null;
+ observeSentimentTrendChart();
+ }
};
sentimentChartAnimationFrame = requestAnimationFrame(frame);
}
@@ -144,9 +177,9 @@ function drawSentimentTrendChart(rows, progress = 1) {
const canvas = document.querySelector("#sentimentTrendChart");
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
const rect = canvas.getBoundingClientRect();
- if (!rect.width) return;
- const width = Math.max(320, rect.width);
- const height = Math.max(220, rect.height);
+ if (rect.width < 8 || rect.height < 8) return;
+ const width = rect.width;
+ const height = rect.height;
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
@@ -258,6 +291,7 @@ function drawSentimentTrendChart(rows, progress = 1) {
const dateText = displayCompactDate(row.trade_date).slice(5);
context.fillText(dateText, x(index), height - padding.bottom + 10);
});
+ sentimentChartLastSize = sentimentChartSizeKey(canvas.closest(".sentiment-chart-shell"));
}
function bindSentimentChartTooltip(rows) {
diff --git a/frontend/shared/auth.css b/frontend/shared/auth.css
index 22d516c..c98f15c 100644
--- a/frontend/shared/auth.css
+++ b/frontend/shared/auth.css
@@ -1693,3 +1693,445 @@ button.account-role-badge:focus-visible {
display: inline-flex;
}
}
+
+.login-portal {
+ min-height: 100vh;
+
+ display: flex;
+
+ background: var(--canvas);
+
+ color: var(--text-primary);
+}
+
+.login-theme-toggle {
+ position: fixed;
+
+ z-index: 2;
+
+ top: 16px;
+
+ right: 20px;
+
+ min-height: 32px;
+
+ padding: 0 12px;
+
+ border: 1px solid var(--border-strong);
+
+ border-radius: var(--size-radius-md);
+
+ background: var(--surface);
+
+ color: var(--text-secondary);
+
+ font-size: var(--font-size-label);
+
+ cursor: pointer;
+}
+
+.login-brand {
+ width: clamp(420px, 34vw, 560px);
+
+ flex: 0 0 auto;
+
+ padding: 44px 48px 36px;
+
+ background: var(--login-brand-gradient);
+
+ color: #f4f7ff;
+}
+
+.login-brand-mark {
+ display: flex;
+
+ align-items: center;
+
+ justify-content: center;
+
+ width: 56px;
+
+ height: 56px;
+
+ border-radius: 16px;
+
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.login-brand-glyph {
+ font-size: 24px;
+
+ font-weight: var(--font-weight-semibold);
+}
+
+.login-brand-kicker {
+ margin: 28px 0 8px;
+
+ font-size: var(--font-size-caption);
+
+ letter-spacing: 0.08em;
+
+ opacity: 0.78;
+}
+
+.login-brand-title {
+ margin: 0;
+
+ font-size: 36px;
+
+ font-weight: var(--font-weight-semibold);
+}
+
+.login-brand-lead {
+ margin: 12px 0 0;
+
+ max-width: 18em;
+
+ font-size: var(--font-size-body);
+
+ line-height: 1.7;
+
+ opacity: 0.86;
+}
+
+.login-brand-stats {
+ display: grid;
+
+ grid-template-columns: 1fr 1fr;
+
+ gap: 16px 20px;
+
+ margin: 40px 0 0;
+}
+
+.login-stat {
+ margin: 0;
+}
+
+.login-stat dt {
+ color: rgba(244, 247, 255, 0.64);
+
+ font-size: var(--font-size-caption);
+}
+
+.login-stat dd {
+ margin: 4px 0 0;
+
+ font-size: 20px;
+
+ font-weight: var(--font-weight-semibold);
+}
+
+.login-stat-tag {
+ margin-left: 6px;
+
+ font-size: var(--font-size-caption);
+
+ font-weight: var(--font-weight-regular);
+}
+
+.login-stage {
+ flex: 1 1 auto;
+
+ display: grid;
+
+ place-items: center;
+
+ padding: 48px 24px;
+}
+
+.login-card {
+ width: 400px;
+
+ max-width: calc(100vw - 48px);
+
+ padding: 32px;
+
+ border: 1px solid var(--border-strong);
+
+ border-radius: var(--size-radius-dialog);
+
+ background: var(--surface);
+
+ box-shadow: var(--shadow-raised);
+}
+
+.login-card.is-loading .login-form {
+ pointer-events: none;
+
+ opacity: 0.72;
+}
+
+.login-card-title {
+ margin: 0;
+
+ font-size: 22px;
+
+ font-weight: var(--font-weight-semibold);
+}
+
+.login-card-lead {
+ margin: 8px 0 0;
+
+ color: var(--text-secondary);
+
+ font-size: var(--font-size-label);
+
+ line-height: 1.6;
+}
+
+.login-tabs {
+ display: grid;
+
+ grid-template-columns: 1fr 1fr;
+
+ margin-top: 24px;
+
+ border-bottom: 1px solid var(--line);
+}
+
+.login-tab {
+ min-height: 40px;
+
+ border: 0;
+
+ border-bottom: 2px solid transparent;
+
+ background: transparent;
+
+ color: var(--text-secondary);
+
+ cursor: pointer;
+}
+
+.login-tab.is-active {
+ border-bottom-color: var(--action);
+
+ color: var(--action);
+
+ font-weight: var(--font-weight-medium);
+}
+
+.login-form {
+ display: grid;
+
+ gap: 14px;
+
+ margin-top: 20px;
+}
+
+.login-portal .form-field input {
+ height: 38px;
+}
+
+.login-submit {
+ width: 100%;
+
+ height: 40px;
+
+ min-height: 40px;
+}
+
+.login-spinner {
+ width: 14px;
+
+ height: 14px;
+
+ border: 2px solid currentColor;
+
+ border-right-color: transparent;
+
+ border-radius: 50%;
+
+ animation: login-spin 0.7s linear infinite;
+}
+
+@keyframes login-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.login-error {
+ margin: 0;
+
+ color: var(--danger);
+
+ font-size: var(--font-size-caption);
+}
+
+.login-error[hidden] {
+ display: none;
+}
+
+.login-hint,
+.login-privacy {
+ margin: 16px 0 0;
+
+ color: var(--text-tertiary);
+
+ font-size: var(--font-size-caption);
+
+ line-height: 1.6;
+}
+
+.login-portal .login-privacy {
+ display: flex;
+
+ align-items: center;
+
+ gap: 6px;
+}
+
+.login-lock {
+ font-size: 12px;
+}
+
+.login-back,
+.login-add,
+.login-manage,
+.login-account-enter,
+.login-account-remove {
+ border: 0;
+
+ background: transparent;
+
+ color: var(--action);
+
+ cursor: pointer;
+}
+
+.login-portal .login-back {
+ margin-bottom: 12px;
+
+ padding: 0;
+
+ font-size: var(--font-size-label);
+}
+
+.login-portal .login-add {
+ display: block;
+
+ width: 100%;
+
+ min-height: 40px;
+
+ margin-top: 8px;
+
+ text-align: left;
+}
+
+.login-portal .login-manage {
+ display: block;
+
+ width: 100%;
+
+ min-height: 40px;
+
+ margin-top: 8px;
+
+ text-align: left;
+
+ color: var(--text-secondary);
+}
+
+.login-account-list {
+ display: grid;
+
+ gap: 8px;
+
+ margin-top: 20px;
+}
+
+.login-account-row {
+ display: flex;
+
+ align-items: center;
+
+ justify-content: space-between;
+
+ min-height: 58px;
+
+ padding: 0 14px;
+
+ border: 1px solid var(--border);
+
+ border-radius: var(--size-radius-md);
+
+ background: var(--surface);
+}
+
+.login-account-row.is-current {
+ border-color: var(--action);
+}
+
+.login-account-row.is-confirming {
+ display: grid;
+
+ gap: 10px;
+
+ padding: 12px 14px;
+}
+
+.login-account-meta {
+ display: grid;
+
+ gap: 2px;
+}
+
+.login-account-meta strong {
+ font-weight: var(--font-weight-medium);
+}
+
+.login-account-meta span {
+ color: var(--text-tertiary);
+
+ font-size: var(--font-size-caption);
+}
+
+.login-account-check {
+ color: var(--action);
+
+ font-size: 16px;
+}
+
+.login-confirm-copy {
+ margin: 0;
+
+ font-size: var(--font-size-label);
+}
+
+.login-confirm-actions {
+ display: flex;
+
+ gap: 8px;
+}
+
+@media (max-width: 900px) {
+ .login-portal {
+ flex-direction: column;
+ }
+
+ .login-brand {
+ width: 100%;
+
+ padding: 20px 20px 16px;
+ }
+
+ .login-brand-lead,
+ .login-brand-stats {
+ display: none;
+ }
+
+ .login-brand-title {
+ font-size: 22px;
+ }
+
+ .login-brand-kicker {
+ margin-top: 12px;
+ }
+
+ .login-stage {
+ padding: 28px 16px 40px;
+ }
+}
diff --git a/frontend/shared/session.js b/frontend/shared/session.js
index 1eaeafa..ac892b3 100644
--- a/frontend/shared/session.js
+++ b/frontend/shared/session.js
@@ -53,12 +53,10 @@ async function applyAuthenticatedSession(session) {
function showAuthGate(message = "") {
state.user = null;
state.csrfToken = "";
- const gate = document.querySelector("#authGate");
- gate.hidden = false;
- const errorElement = document.querySelector("#authError");
- errorElement.textContent = message;
- errorElement.hidden = !message;
- document.querySelector("#authUsername").focus();
+ const params = new URLSearchParams();
+ if (message) params.set("notice", message);
+ const query = params.toString();
+ window.location.replace("/login/" + (query ? `?${query}` : ""));
}
async function logoutAccount() {
@@ -197,16 +195,8 @@ async function changeAccountPassword(event) {
}
async function switchAccount() {
- const button = document.querySelector("#switchAccountMenuButton");
- button.disabled = true;
toggleAccountDropdown(false);
- try {
- await apiRequest("/api/auth/logout", "POST", {});
- window.location.reload();
- } catch (error) {
- showToast(error.message || "切换账号失败");
- button.disabled = false;
- }
+ window.location.assign("/login/");
}
diff --git a/frontend/shared/tokens.css b/frontend/shared/tokens.css
index 5391ed9..3102d33 100644
--- a/frontend/shared/tokens.css
+++ b/frontend/shared/tokens.css
@@ -146,6 +146,7 @@
--shadow-float: var(--elevation-float);
--duration-fast: 150ms;
--duration-normal: 220ms;
+ --login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc);
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
@@ -210,6 +211,9 @@
--pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
--sentiment-history-max-height: 510px;
--sentiment-history-min-height: 220px;
+ --sentiment-analysis-height: 600px;
+ --sentiment-tooltip-bg: var(--text-primary);
+ --sentiment-tooltip-fg: var(--text-inverse);
--primary-share: 1.45fr;
--secondary-share: .75fr;
--mobile-nav-height: 64px;
@@ -505,10 +509,13 @@
--chart-repair: #e2ad58;
--chart-ma-10: #d39a45;
--chart-ma-20: #9aa5af;
+ --sentiment-tooltip-bg: #26293e;
+ --sentiment-tooltip-fg: #e8eaed;
--on-action: #101418;
--warning-line: #6d5a38;
--warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+ --login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a);
--dialog-backdrop: var(--backdrop);
--ladder-level-1: #2d2426;
--ladder-level-2: #2b2822;
diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js
index 49c927e..b3422a5 100644
--- a/tests/e2e/app-shell.spec.js
+++ b/tests/e2e/app-shell.spec.js
@@ -47,6 +47,72 @@ function session(role = "admin", subscribed = true) {
};
}
+function sentimentHistoryPayload(days = 20) {
+ const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"];
+ const rows = Array.from({ length: days }, (_, index) => {
+ const score = 28 + ((index * 7) % 55);
+ return {
+ trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`,
+ score,
+ label: "情绪观察",
+ phase: phases[index % phases.length],
+ direction: index % 2 ? "升温" : "降温",
+ day_change: index % 2 ? 3.2 : -2.1,
+ seal_rate: 71.5,
+ limit_up_count: 40 + index,
+ first_board_count: 18,
+ second_board_count: 8,
+ three_plus_count: 4,
+ max_height: 5,
+ broken_count: 12,
+ limit_down_count: 3,
+ previous_limit_count: 38,
+ previous_positive_count: 22,
+ previous_positive_rate: 57.9,
+ average_previous_change: 1.2,
+ normalization: "固定锚点",
+ components: {
+ breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" },
+ limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" },
+ profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" },
+ ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" },
+ amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" },
+ },
+ };
+ });
+ return { available_days: days, rows };
+}
+
+async function renderSentimentFixture(page, days = 20) {
+ await page.evaluate((payload) => {
+ state.sentimentHistory = payload;
+ renderSentimentHistory();
+ }, sentimentHistoryPayload(days));
+ await page.locator('[data-view="sentimentCycleView"]').first().click();
+ await page.waitForTimeout(900);
+}
+
+function readSentimentLayout() {
+ const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
+ const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
+ const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
+ const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
+ const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
+ const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
+ const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect();
+ const small = document.querySelector(".sentiment-component-item small");
+ return {
+ topDelta: Math.abs(trend.top - summary.top),
+ bottomDelta: Math.abs(trend.bottom - components.bottom),
+ analysisHeight: analysis.height,
+ chartHeight: chart.height,
+ detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5,
+ tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom,
+ smallVisible: small ? getComputedStyle(small).display !== "none" : false,
+ overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
+ };
+}
+
function waitForApplicationRuntime(page) {
return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true");
}
@@ -721,6 +787,7 @@ test("collapsed overview and sentiment layout keep a single current reading", as
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => {
+ const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
@@ -732,6 +799,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return {
columnsAligned: Math.abs(trend.top - summary.top) < 1,
+ bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1,
+ analysisHeight: analysis.height,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height,
@@ -743,6 +812,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as
};
});
expect(alignment.columnsAligned).toBe(true);
+ expect(alignment.bottomsAligned).toBe(true);
+ expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1);
expect(alignment.railAligned).toBe(true);
expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
@@ -780,6 +851,68 @@ test("collapsed overview and sentiment layout keep a single current reading", as
}
});
+test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => {
+ const shotDir = testInfo.outputPath("hel-221-shots");
+ await mockApplication(page, session("user", true));
+ const viewports = [
+ { name: "zoom-100", width: 2560, height: 1440 },
+ { name: "zoom-110", width: 2327, height: 1309 },
+ { name: "zoom-125", width: 2048, height: 1152 },
+ ];
+
+ for (const viewport of viewports) {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await page.goto("/index.html");
+ await renderSentimentFixture(page, 20);
+ const layout = await page.evaluate(readSentimentLayout);
+ expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1);
+ expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1);
+ expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1);
+ expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450);
+ expect(layout.detailAfterAnalysis, viewport.name).toBe(true);
+ expect(layout.tableVisible, viewport.name).toBe(true);
+ expect(layout.smallVisible, viewport.name).toBe(true);
+ expect(layout.overflowX, viewport.name).toBe(false);
+ await page.screenshot({
+ path: `${shotDir}/day-${viewport.name}.png`,
+ fullPage: true,
+ });
+ }
+
+ await page.setViewportSize({ width: 2560, height: 1440 });
+ await page.locator("#themeToggle").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await page.waitForTimeout(200);
+ const nightLayout = await page.evaluate(readSentimentLayout);
+ expect(nightLayout.topDelta).toBeLessThanOrEqual(1);
+ expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1);
+ expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1);
+ await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
+ const tooltip = page.locator("#sentimentChartTooltip");
+ await expect(tooltip).toBeVisible();
+ await expect(tooltip).toContainText("温度");
+ const tooltipStyle = await tooltip.evaluate((node) => {
+ const style = getComputedStyle(node);
+ const bold = getComputedStyle(node.querySelector("b") || node);
+ return { background: style.backgroundColor, color: style.color, bold: bold.color };
+ });
+ expect(tooltipStyle.background).toBe("rgb(38, 41, 62)");
+ expect(tooltipStyle.color).toBe("rgb(232, 234, 237)");
+ expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)");
+ await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true });
+
+ await page.locator("#themeToggle").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
+ await expect(tooltip).toBeVisible();
+ const lightTooltip = await tooltip.evaluate((node) => {
+ const style = getComputedStyle(node);
+ return { background: style.backgroundColor, color: style.color };
+ });
+ expect(lightTooltip.background).toBe("rgb(31, 35, 41)");
+ expect(lightTooltip.color).toBe("rgb(255, 255, 255)");
+});
+
test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
diff --git a/tests/e2e/login-portal.spec.js b/tests/e2e/login-portal.spec.js
new file mode 100644
index 0000000..b31b913
--- /dev/null
+++ b/tests/e2e/login-portal.spec.js
@@ -0,0 +1,151 @@
+const { test, expect } = require("@playwright/test");
+
+function loginPayload(user) {
+ return {
+ ok: true,
+ authenticated: true,
+ csrf_token: "portal-csrf",
+ user,
+ };
+}
+
+async function mockLoginPortal(page, options = {}) {
+ const accounts = options.accounts || [];
+ let currentUserId = options.currentUserId ?? null;
+ await page.route("**/api/**", async (route) => {
+ const url = new URL(route.request().url());
+ const method = route.request().method();
+ if (url.pathname === "/api/auth/accounts") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ ok: true, accounts, current_user_id: currentUserId }),
+ });
+ return;
+ }
+ if (url.pathname === "/api/auth/switch" && method === "POST") {
+ const body = route.request().postDataJSON() || {};
+ const account = accounts.find((item) => Number(item.user_id) === Number(body.user_id));
+ if (!account || options.switchFails) {
+ await route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "该账号需重新验证" }),
+ });
+ return;
+ }
+ currentUserId = account.user_id;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(loginPayload(account)),
+ });
+ return;
+ }
+ if (url.pathname === "/api/auth/forget" && method === "POST") {
+ const body = route.request().postDataJSON() || {};
+ const index = accounts.findIndex((item) => Number(item.user_id) === Number(body.user_id));
+ if (index >= 0) accounts.splice(index, 1);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ ok: true }),
+ });
+ return;
+ }
+ if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(loginPayload({
+ id: 9,
+ username: "new_user",
+ role: "user",
+ membership: { active: false, subscribed: false, is_admin: false },
+ })),
+ });
+ return;
+ }
+ if (url.pathname === "/api/auth/me") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ok: true,
+ authenticated: Boolean(currentUserId),
+ csrf_token: "portal-csrf",
+ user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
+ }),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ ok: true }),
+ });
+ });
+}
+
+const SAVED_ACCOUNTS = [
+ {
+ user_id: 1,
+ username: "alpha_user",
+ role: "admin",
+ membership: { active: true, subscribed: true, is_admin: true },
+ last_used_at: "2026-08-29T01:00:00+00:00",
+ },
+ {
+ user_id: 2,
+ username: "beta_user",
+ role: "user",
+ membership: { active: false, subscribed: false, is_admin: false },
+ last_used_at: "2026-08-28T01:00:00+00:00",
+ },
+];
+
+test("first-time login portal asks for a password and hides environment copy", async ({ page }) => {
+ await mockLoginPortal(page, { accounts: [] });
+ await page.goto("/login/");
+ await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
+ await expect(page.locator("#loginUsername")).toBeVisible();
+ await expect(page.locator(".login-submit")).toHaveText("登录");
+ await expect(page.locator("body")).not.toContainText("内网个人版");
+ await expect(page.locator("body")).not.toContainText("192.168.200.11");
+});
+
+test("saved accounts can switch directly and show a re-auth message on failure", async ({ page }) => {
+ await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
+ await page.goto("/login/");
+ await expect(page.locator(".login-card-title")).toHaveText("选择账号");
+ await expect(page.locator(".login-account-row")).toHaveCount(2);
+ const switched = page.waitForRequest((request) => (
+ request.url().includes("/api/auth/switch") && request.method() === "POST"
+ ));
+ await page.locator('[data-switch-id="2"]').click();
+ const request = await switched;
+ expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 });
+});
+
+test("failed account switch stays on the portal with the original copy", async ({ page }) => {
+ await mockLoginPortal(page, {
+ accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
+ switchFails: true,
+ });
+ await page.goto("/login/");
+ await page.locator('[data-switch-id="2"]').click();
+ await expect(page.locator(".login-error")).toHaveText("该账号需重新验证");
+ await expect(page).toHaveURL(/\/login\/?/);
+});
+
+test("managing accounts removes a local record after inline confirmation", async ({ page }) => {
+ await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
+ await page.goto("/login/");
+ await page.locator('[data-login-action="manage"]').click();
+ await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
+ await page.locator('[data-confirm-id="2"]').click();
+ await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
+ await page.locator('[data-forget-id="2"]').click();
+ await expect(page.locator(".login-account-row")).toHaveCount(1);
+ await expect(page.locator(".login-account-row")).toContainText("alpha_user");
+});
diff --git a/tests/e2e/mobile-app.spec.js b/tests/e2e/mobile-app.spec.js
index 2a5869d..e532b7e 100644
--- a/tests/e2e/mobile-app.spec.js
+++ b/tests/e2e/mobile-app.spec.js
@@ -1,7 +1,7 @@
const { test, expect } = require("@playwright/test");
-// 手机端(/m/)全页面回归:P5 收官打磨。
-// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
+// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。
+// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页,
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
const EMPTY_DASHBOARD = {
@@ -138,6 +138,31 @@ async function mockMobileApi(page, options = {}) {
payload = { items: [] };
} else if (path === "/api/search") {
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
+ } else if (path === "/api/account/status") {
+ payload = {
+ birth_profile_configured: true,
+ birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" },
+ llm_access: {
+ daily_limit: 50,
+ used_today: 3,
+ remaining_calls: 47,
+ membership: {
+ active: true,
+ subscribed: true,
+ is_admin: auth.user.role === "admin",
+ remaining_days: 20,
+ expires_at: "2026-09-18",
+ plan: "会员",
+ },
+ },
+ };
+ } else if (path === "/api/admin/settings") {
+ payload = {
+ data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } },
+ llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" },
+ membership: { member_daily_limit: 50 },
+ users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }],
+ };
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
payload = {
meta: { trade_date: "2026-07-22", intraday_status: "available" },
@@ -230,6 +255,16 @@ const REVIEW_PAGES = [
["review/alerts", "提醒中心"],
];
+const SYSTEM_PAGES = [
+ ["system/profile", "账号资料"],
+ ["system/password", "修改密码"],
+ ["system/membership", "会员状态"],
+ ["system/admin", "系统设置"],
+ ["system/members", "会员管理"],
+];
+
+const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
+
test("mobile login renders before authentication", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/api/**", async (route) => {
@@ -296,6 +331,54 @@ test("review five pages render watchlist, trades, daily, notes and alerts", asyn
}
});
+test("system management pages render real content instead of placeholders", async ({ page }) => {
+ await mockMobileApi(page);
+ await openMobile(page);
+ for (const [key, label] of SYSTEM_PAGES) {
+ await navigateToFeature(page, key);
+ await expect(page.locator("#m-title")).toHaveText(label);
+ await expect(page.locator(".m-placeholder")).toHaveCount(0);
+ await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
+ await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible();
+ expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
+ }
+ await navigateToFeature(page, "system/profile");
+ await expect(page.locator("#m-sys-birth-date")).toBeVisible();
+ await expect(page.locator("[data-system-switch]")).toBeVisible();
+ await navigateToFeature(page, "system/password");
+ await expect(page.locator("#m-sys-password-current")).toBeVisible();
+ await navigateToFeature(page, "system/membership");
+ await expect(page.locator(".m-sys-grid")).toBeVisible();
+ await navigateToFeature(page, "system/admin");
+ await expect(page.locator("#m-sys-token")).toBeVisible();
+ await navigateToFeature(page, "system/members");
+ await expect(page.locator("#m-sys-member-limit")).toBeVisible();
+});
+
+test("empty profile save click shows a toast instead of a dead button", async ({ page }) => {
+ await mockMobileApi(page);
+ await openMobile(page);
+ await navigateToFeature(page, "system/profile");
+ await expect(page.locator("[data-system-save-birth]")).toBeVisible();
+ await page.locator("#m-sys-birth-date").fill("");
+ await page.locator("#m-sys-birth-time").fill("");
+ await page.locator("[data-system-save-birth]").click();
+ await expect(page.locator("#m-toast.is-visible")).toBeVisible();
+ await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间");
+});
+
+test("non-admin cannot open system admin pages as placeholders", async ({ page }) => {
+ await mockMobileApi(page, { auth: authSession("user", true) });
+ await openMobile(page);
+ await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
+ await expect(page.locator(".m-hub-grid")).toBeVisible();
+ await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0);
+ await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0);
+ await navigateToFeature(page, "system/admin");
+ await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
+ await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
+});
+
test("assistant chat renders with presets and input", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
diff --git a/tests/test_account_switch_grants.py b/tests/test_account_switch_grants.py
new file mode 100644
index 0000000..b282cdb
--- /dev/null
+++ b/tests/test_account_switch_grants.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import threading
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from backend.features.accounts.security import SecretVault, token_hash
+from backend.features.accounts.service import AccountService
+from database import ReviewDatabase
+
+
+class AccountSwitchGrantTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temp = TemporaryDirectory()
+ self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
+ self.bound_user_id = 0
+ self.service = AccountService(
+ database=self.database,
+ vault=SecretVault(SecretVault.generate_key()),
+ current_user_supplier=lambda: self.bound_user_id,
+ access_supplier=lambda: self.database.user_access(self.bound_user_id) or {},
+ bind_user=self._bind,
+ personal_field_builder=lambda *args, **kwargs: {},
+ auth_lock=threading.Lock(),
+ )
+ self.device_a = token_hash("device-a-token")
+ self.device_b = token_hash("device-b-token")
+
+ def tearDown(self) -> None:
+ self.temp.cleanup()
+
+ def _bind(self, user_id: int) -> None:
+ self.bound_user_id = int(user_id)
+
+ def _register(self, username: str, device_hash: str = "") -> dict:
+ return self.service.register(username, "Password123", device_hash or self.device_a)
+
+ def test_login_records_accounts_for_the_current_device_only(self) -> None:
+ first = self._register("alpha_user")
+ second = self._register("beta_user")
+ self.service.login("alpha_user", "Password123", self.device_b)
+
+ listed = self.service.list_device_accounts(self.device_a)
+ names = [item["username"] for item in listed["accounts"]]
+ self.assertEqual(names, ["beta_user", "alpha_user"])
+ self.assertEqual(
+ self.service.list_device_accounts(self.device_b)["accounts"][0]["username"],
+ "alpha_user",
+ )
+ self.assertEqual(self.service.list_device_accounts("")["accounts"], [])
+ self.assertEqual(first["user"]["username"], "alpha_user")
+ self.assertEqual(second["user"]["username"], "beta_user")
+
+ def test_switch_uses_device_grant_and_keeps_the_original_authorization(self) -> None:
+ first = self._register("alpha_user")
+ self._register("beta_user")
+ switched = self.service.switch_account(self.device_a, int(first["user"]["id"]))
+ self.assertEqual(switched["user"]["username"], "alpha_user")
+ remaining = {
+ item["username"]
+ for item in self.service.list_device_accounts(self.device_a)["accounts"]
+ }
+ self.assertEqual(remaining, {"alpha_user", "beta_user"})
+
+ def test_switch_without_a_valid_grant_requires_reauthentication(self) -> None:
+ user = self._register("alpha_user")
+ with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
+ self.service.switch_account(self.device_b, int(user["user"]["id"]))
+ with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
+ self.service.switch_account("", int(user["user"]["id"]))
+
+ def test_forget_only_removes_the_current_device_grant(self) -> None:
+ user = self._register("alpha_user")
+ self.service.login("alpha_user", "Password123", self.device_b)
+ self.service.forget_account(self.device_a, int(user["user"]["id"]))
+ self.service.forget_account(self.device_a, int(user["user"]["id"]))
+ self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
+ self.assertEqual(
+ self.service.list_device_accounts(self.device_b)["accounts"][0]["username"],
+ "alpha_user",
+ )
+
+ def test_logout_revokes_only_the_current_account_on_this_device(self) -> None:
+ first = self._register("alpha_user")
+ second = self._register("beta_user")
+ self.service.revoke_current_device_grant(self.device_a, int(second["user"]["id"]))
+ names = {
+ item["username"]
+ for item in self.service.list_device_accounts(self.device_a)["accounts"]
+ }
+ self.assertEqual(names, {"alpha_user"})
+ switched = self.service.switch_account(self.device_a, int(first["user"]["id"]))
+ self.assertEqual(switched["user"]["id"], first["user"]["id"])
+
+ def test_password_change_revokes_grants_on_every_device(self) -> None:
+ user = self._register("alpha_user")
+ self.service.login("alpha_user", "Password123", self.device_b)
+ self._bind(int(user["user"]["id"]))
+ self.service.change_password("Password123", "Password456")
+ self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
+ self.assertEqual(self.service.list_device_accounts(self.device_b)["accounts"], [])
+ with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
+ self.service.switch_account(self.device_a, int(user["user"]["id"]))
+
+ def test_device_keeps_at_most_five_accounts(self) -> None:
+ usernames = [f"user_{index}" for index in range(6)]
+ ids = [self._register(name)["user"]["id"] for name in usernames]
+ listed = self.service.list_device_accounts(self.device_a)["accounts"]
+ self.assertEqual(len(listed), 5)
+ kept = {item["user_id"] for item in listed}
+ self.assertNotIn(ids[0], kept)
+ self.assertTrue(set(ids[1:]).issubset(kept))
+
+ def test_expired_grants_are_removed_lazily(self) -> None:
+ user = self._register("alpha_user")
+ past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(timespec="seconds")
+ self.database.upsert_switch_grant(
+ self.device_a,
+ int(user["user"]["id"]),
+ past,
+ past,
+ past,
+ )
+ self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
+ with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
+ self.service.switch_account(self.device_a, int(user["user"]["id"]))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py
index 4c72bf3..6ee6379 100644
--- a/tests/test_database_migrations.py
+++ b/tests/test_database_migrations.py
@@ -25,6 +25,7 @@ class DatabaseMigrationTests(unittest.TestCase):
("0002", "create_job_runs"),
("0003", "extend_llm_audit"),
("0004", "add_mentor_note"),
+ ("0005", "create_account_switch_grants"),
],
)
columns = {
@@ -39,7 +40,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
- self.assertEqual(count, 4)
+ self.assertEqual(count, 5)
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
self,
@@ -60,7 +61,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
- self.assertEqual(count, 4)
+ self.assertEqual(count, 5)
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
with tempfile.TemporaryDirectory() as root:
@@ -87,7 +88,7 @@ class DatabaseMigrationTests(unittest.TestCase):
"PRAGMA table_info(mentor_preferences)"
)
]
- self.assertEqual(versions, {"0001", "0002", "0003", "0004"})
+ self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
self.assertIn("note", note_rows)
def test_database_with_unknown_migration_is_rejected(self) -> None:
diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py
index ab8f0fd..e2657b7 100644
--- a/tests/test_frontend_contract.py
+++ b/tests/test_frontend_contract.py
@@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
self.assertIn("overflow: auto;", self.sentiment_styles)
+ def test_sentiment_equal_height_and_tooltip_tokens(self):
+ self.assertIn("--sentiment-analysis-height: 600px;", self.tokens)
+ self.assertIn("--sentiment-tooltip-bg: var(--text-primary);", self.tokens)
+ self.assertIn("--sentiment-tooltip-fg: var(--text-inverse);", self.tokens)
+ self.assertIn("--sentiment-tooltip-bg: #26293e;", self.tokens)
+ self.assertIn("--sentiment-tooltip-fg: #e8eaed;", self.tokens)
+ self.assertIn("height: var(--sentiment-analysis-height);", self.sentiment_styles)
+ self.assertIn("max-height: var(--sentiment-analysis-height);", self.sentiment_styles)
+ self.assertIn("justify-content: space-evenly;", self.sentiment_styles)
+ self.assertIn("background: var(--sentiment-tooltip-bg);", self.sentiment_styles)
+ self.assertIn("color: var(--sentiment-tooltip-fg);", self.sentiment_styles)
+ self.assertIn("new ResizeObserver", self.script)
+ self.assertIn("#sentimentCycleView .redesigned-emotion-grid {", self.sentiment_styles)
+ self.assertNotIn("height: 100vh", self.sentiment_styles)
+ self.assertNotIn("min-height: 100%", self.sentiment_styles)
+
def test_mentor_final_visual_fix_contract(self):
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
diff --git a/tests/test_governance_registries.py b/tests/test_governance_registries.py
index f01c9cb..f8acd51 100644
--- a/tests/test_governance_registries.py
+++ b/tests/test_governance_registries.py
@@ -65,8 +65,11 @@ class GovernanceRegistryTests(unittest.TestCase):
public,
{
("GET", "/api/health"),
+ ("GET", "/api/auth/accounts"),
("POST", "/api/auth/login"),
("POST", "/api/auth/register"),
+ ("POST", "/api/auth/switch"),
+ ("POST", "/api/auth/forget"),
},
)
diff --git a/tests/test_mobile_system_pages.py b/tests/test_mobile_system_pages.py
new file mode 100644
index 0000000..4634819
--- /dev/null
+++ b/tests/test_mobile_system_pages.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import re
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+NAV = ROOT / "frontend/m/config/nav.config.js"
+PAGES = ROOT / "frontend/m/js/pages.js"
+ROUTER = ROOT / "frontend/m/js/router.js"
+
+
+class MobileSystemPagesRegressionTests(unittest.TestCase):
+ """Prevent mobile system-management entries from falling back to placeholders."""
+
+ def test_nav_system_entries_are_registered_as_real_pages(self) -> None:
+ nav = NAV.read_text(encoding="utf-8")
+ pages = PAGES.read_text(encoding="utf-8")
+ keys = re.findall(r'key:\s*"(system/[^"]+)"', nav)
+ self.assertEqual(
+ keys,
+ [
+ "system/profile",
+ "system/password",
+ "system/membership",
+ "system/admin",
+ "system/members",
+ ],
+ )
+ for key in keys:
+ self.assertIn(f'"{key}": setupSystemPage', pages)
+ self.assertIn(f'"{key}": loadSystem', pages)
+
+ def test_placeholder_copy_is_only_a_router_fallback(self) -> None:
+ router = ROUTER.read_text(encoding="utf-8")
+ pages = PAGES.read_text(encoding="utf-8")
+ self.assertIn("该功能页将在后续批次实现", router)
+ self.assertNotIn("该功能页将在后续批次实现", pages)
+ self.assertIn("function setupSystemPage", pages)
+ self.assertIn("function loadSystem", pages)
+
+ def test_system_pages_render_real_controls_not_stubs(self) -> None:
+ pages = PAGES.read_text(encoding="utf-8")
+ for marker in (
+ 'data-system-page="profile"',
+ 'data-system-page="password"',
+ 'data-system-page="membership"',
+ 'data-system-page="members"',
+ 'data-system-page="forbidden"',
+ 'data-system-admin-panel="market"',
+ "m-sys-birth-date",
+ "m-sys-password-current",
+ "m-sys-token",
+ "m-sys-member-limit",
+ "data-system-switch",
+ 'location.assign("/login/")',
+ ):
+ self.assertIn(marker, pages)
+
+ def test_system_boolean_attrs_do_not_have_stray_quotes(self) -> None:
+ pages = PAGES.read_text(encoding="utf-8")
+ stray = re.findall(r'data-system-[a-z-]*"(?=[>\s])', pages)
+ self.assertEqual(
+ stray,
+ [],
+ "boolean data-system attributes must not have a trailing quote before > or space",
+ )
+ for name in (
+ "data-system-save-birth",
+ "data-system-save-password",
+ "data-system-add-model",
+ "data-system-save-models",
+ "data-system-save-market",
+ "data-system-refresh",
+ ):
+ self.assertIn(name, pages)
+ self.assertNotIn(name + '">', pages)
diff --git a/tools/build_api_registry.py b/tools/build_api_registry.py
index 2ee6443..ea775f3 100644
--- a/tools/build_api_registry.py
+++ b/tools/build_api_registry.py
@@ -50,7 +50,13 @@ def _owner(path: str) -> str:
def _role(method: str, path: str) -> str:
- if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}:
+ if path == "/api/health" or path in {
+ "/api/auth/register",
+ "/api/auth/login",
+ "/api/auth/accounts",
+ "/api/auth/switch",
+ "/api/auth/forget",
+ }:
return "public"
from api_access import required_role