fix(HEL-243): 当前账号可点击返回,避免切换死路
当前账号行原先只显示对号、不可点击,单账号时只能关网页。现改为整行继续使用且不重新签发会话,并带回切换前的业务页。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
e29d5115fa
commit
d2a165ada8
@@ -553,16 +553,16 @@
|
||||
"bytes": 13681,
|
||||
"lines": 338
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/session.js",
|
||||
"bytes": 13219,
|
||||
"lines": 289
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/dashboard.js",
|
||||
"bytes": 12894,
|
||||
"lines": 274
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/session.js",
|
||||
"bytes": 12848,
|
||||
"lines": 283
|
||||
},
|
||||
{
|
||||
"path": "backend/features/market/insights_auction_data.py",
|
||||
"bytes": 12829,
|
||||
@@ -673,16 +673,16 @@
|
||||
"bytes": 5451,
|
||||
"lines": 118
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages.config.js",
|
||||
"bytes": 5385,
|
||||
"lines": 130
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/market/search.js",
|
||||
"bytes": 5384,
|
||||
"lines": 131
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages.config.js",
|
||||
"bytes": 5380,
|
||||
"lines": 130
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/auction/page.html",
|
||||
"bytes": 5350,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel240">
|
||||
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
|
||||
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel240b">
|
||||
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel243">
|
||||
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
|
||||
</head>
|
||||
<body class="login-portal">
|
||||
@@ -90,6 +90,6 @@
|
||||
<section class="login-card" id="loginCard" aria-live="polite"></section>
|
||||
</main>
|
||||
<script src="/shared/api.js?v=20260803-2"></script>
|
||||
<script src="/login/page.js?v=20260829-hel240"></script>
|
||||
<script src="/login/page.js?v=20260829-hel243"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+50
-5
@@ -77,9 +77,22 @@
|
||||
return chips.join("");
|
||||
}
|
||||
|
||||
function returnPath() {
|
||||
const raw = new URLSearchParams(global.location.search).get("next") || "";
|
||||
if (!raw) return "/";
|
||||
try {
|
||||
const url = new URL(raw, global.location.origin);
|
||||
if (url.origin !== global.location.origin) return "/";
|
||||
const path = url.pathname || "/";
|
||||
if (path === "/login" || path.startsWith("/login/")) return "/";
|
||||
return `${path}${url.search}${url.hash}` || "/";
|
||||
} catch (_error) {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
function enterApp() {
|
||||
const next = new URLSearchParams(global.location.search).get("next");
|
||||
global.location.replace(next && next.startsWith("/") ? next : "/");
|
||||
global.location.replace(returnPath());
|
||||
}
|
||||
|
||||
function formMarkup(options) {
|
||||
@@ -120,7 +133,7 @@
|
||||
"login-account-row",
|
||||
current ? "is-current" : "",
|
||||
confirming ? "is-confirming" : "",
|
||||
!managing && !current ? "is-switchable" : "",
|
||||
!managing ? "is-switchable" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
if (managing && confirming) {
|
||||
return [
|
||||
@@ -138,11 +151,12 @@
|
||||
const action = managing
|
||||
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}" aria-label="移除 ${escapeHtml(account.username)}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M6 2h4l.5 1H14v1H2V3h3.5L6 2zm1 4v6H6V6h1zm3 0v6H9V6h1zM3.5 5H13l-.7 8.2A1.5 1.5 0 0 1 10.81 14H5.19a1.5 1.5 0 0 1-1.49-1.8L3.5 5z"></path></svg></button>`
|
||||
: current
|
||||
? '<span class="login-account-check" aria-hidden="true">✓</span>'
|
||||
? '<span class="login-account-action"><span class="login-account-check" aria-hidden="true">✓</span>继续使用</span>'
|
||||
: "";
|
||||
const switchAttr = !managing && !current ? ` data-switch-id="${account.user_id}"` : "";
|
||||
const resumeAttr = !managing && current ? ` data-resume-id="${account.user_id}"` : "";
|
||||
return [
|
||||
`<div class="${classes}" data-user-id="${account.user_id}"${switchAttr}>`,
|
||||
`<div class="${classes}" data-user-id="${account.user_id}"${switchAttr}${resumeAttr}>`,
|
||||
`<span class="login-avatar tone-${tone}" aria-hidden="true">${glyph}</span>`,
|
||||
'<div class="login-account-meta">',
|
||||
'<div class="login-account-name">',
|
||||
@@ -160,6 +174,9 @@
|
||||
const count = state.accounts.length;
|
||||
const managing = state.view === "manage";
|
||||
return [
|
||||
managing
|
||||
? ""
|
||||
: '<button class="login-back" type="button" data-login-action="resume">返回复盘</button>',
|
||||
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
|
||||
`<p class="login-card-lead">${managing
|
||||
? "移除只删除这台电脑上的登录记录,不会注销账号"
|
||||
@@ -210,6 +227,10 @@
|
||||
card.querySelectorAll("[data-login-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const action = button.dataset.loginAction;
|
||||
if (action === "resume") {
|
||||
resumeCurrentAccount();
|
||||
return;
|
||||
}
|
||||
if (action === "picker") {
|
||||
state.view = state.accounts.length ? "picker" : "first";
|
||||
state.confirmingId = null;
|
||||
@@ -228,6 +249,9 @@
|
||||
card.querySelectorAll("[data-switch-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId)));
|
||||
});
|
||||
card.querySelectorAll("[data-resume-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => resumeCurrentAccount());
|
||||
});
|
||||
card.querySelectorAll("[data-confirm-id]").forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -291,6 +315,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeCurrentAccount() {
|
||||
state.loading = true;
|
||||
setError("");
|
||||
render();
|
||||
try {
|
||||
const session = await api.request("/api/auth/me");
|
||||
const sessionUserId = session.user?.id;
|
||||
const matches = Boolean(session.authenticated) && (
|
||||
!state.currentUserId || Number(sessionUserId) === Number(state.currentUserId)
|
||||
);
|
||||
if (!matches) {
|
||||
throw new Error("当前会话已失效,请重新登录");
|
||||
}
|
||||
enterApp();
|
||||
} catch (error) {
|
||||
state.loading = false;
|
||||
setError(error.message || "当前会话已失效,请重新登录");
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetAccount(userId) {
|
||||
try {
|
||||
await api.request("/api/auth/forget", "POST", { user_id: userId });
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"/shared/table.js?v=20260803-1",
|
||||
"/shared/theme.js?v=20260803-1",
|
||||
"/shared/dashboard.js?v=20260820-1",
|
||||
"/shared/session.js?v=20260803-1",
|
||||
"/shared/session.js?v=20260829-hel243",
|
||||
"/shared/admin.js?v=20260803-1",
|
||||
"/app.js?v=20260803-2",
|
||||
];
|
||||
|
||||
@@ -2389,6 +2389,18 @@ body.login-portal {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-account-action {
|
||||
display: grid;
|
||||
|
||||
justify-items: end;
|
||||
|
||||
gap: 2px;
|
||||
|
||||
color: var(--action);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.login-account-remove {
|
||||
display: grid;
|
||||
|
||||
|
||||
@@ -196,7 +196,13 @@ async function changeAccountPassword(event) {
|
||||
|
||||
async function switchAccount() {
|
||||
toggleAccountDropdown(false);
|
||||
window.location.assign("/login/");
|
||||
const params = new URLSearchParams();
|
||||
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (next.startsWith("/") && !next.startsWith("//") && next !== "/login" && !next.startsWith("/login/") && !next.startsWith("/login?")) {
|
||||
params.set("next", next);
|
||||
}
|
||||
const query = params.toString();
|
||||
window.location.assign("/login/" + (query ? `?${query}` : ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,14 +81,58 @@ async function mockLoginPortal(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/me") {
|
||||
const current = accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null;
|
||||
const authenticated = Boolean(current) && !options.sessionExpired;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
authenticated: Boolean(currentUserId),
|
||||
authenticated,
|
||||
csrf_token: "portal-csrf",
|
||||
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
|
||||
user: authenticated ? {
|
||||
id: current.user_id,
|
||||
username: current.username,
|
||||
role: current.role,
|
||||
membership: current.membership,
|
||||
} : null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dashboard") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
meta: {
|
||||
trade_date: "2026-07-22",
|
||||
requested_date: "2026-07-22",
|
||||
source: "tushare",
|
||||
realtime: false,
|
||||
cached: true,
|
||||
market_status: "closed",
|
||||
updated_at: "2026-07-22T15:00:00+08:00",
|
||||
},
|
||||
overview: {
|
||||
up_count: 2100,
|
||||
down_count: 2800,
|
||||
limit_up_count: 42,
|
||||
limit_down_count: 8,
|
||||
broken_count: 17,
|
||||
seal_rate: 71.2,
|
||||
amount_billion: 12600,
|
||||
sentiment_score: 48,
|
||||
},
|
||||
limits: [],
|
||||
broken: [],
|
||||
down_limits: [],
|
||||
yesterday_limits: [],
|
||||
limit_performance: [],
|
||||
ladders: [],
|
||||
sectors: [],
|
||||
sector_rotation: [],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -289,3 +333,114 @@ test("loading button appears on the confirmed first-login skeleton", async ({ pa
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true });
|
||||
await submit;
|
||||
});
|
||||
|
||||
async function openPicker(page, options = {}) {
|
||||
const accounts = options.accounts || SAVED_ACCOUNTS.map((item) => ({ ...item }));
|
||||
const currentUserId = options.currentUserId ?? 1;
|
||||
const next = options.next || "/index.html?view=sentimentCycleView";
|
||||
await page.unroute("**/api/**").catch(() => {});
|
||||
await mockLoginPortal(page, {
|
||||
accounts,
|
||||
currentUserId,
|
||||
sessionExpired: options.sessionExpired,
|
||||
switchFails: options.switchFails,
|
||||
});
|
||||
await page.goto(`/login/?next=${encodeURIComponent(next)}`);
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
}
|
||||
|
||||
test("clicking the current account from two workspace pages returns without switching", async ({ page }) => {
|
||||
const views = ["sentimentCycleView", "ladderView"];
|
||||
for (const viewId of views) {
|
||||
const next = `/index.html?view=${viewId}`;
|
||||
const switchCalls = [];
|
||||
const onRequest = (request) => {
|
||||
if (request.url().includes("/api/auth/switch") && request.method() === "POST") {
|
||||
switchCalls.push(request);
|
||||
}
|
||||
};
|
||||
page.on("request", onRequest);
|
||||
await openPicker(page, { next });
|
||||
await expect(page.locator('[data-resume-id="1"]')).toContainText("继续使用");
|
||||
await expect(page.locator('[data-resume-id="1"]')).toContainText("当前");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]view=${viewId}\\b`));
|
||||
expect(switchCalls).toEqual([]);
|
||||
page.off("request", onRequest);
|
||||
}
|
||||
});
|
||||
|
||||
test("a lone current account can return from the picker instead of dead-ending", async ({ page }) => {
|
||||
await openPicker(page, {
|
||||
accounts: [SAVED_ACCOUNTS[0]],
|
||||
currentUserId: 1,
|
||||
next: "/index.html?view=reviewWorkspaceView",
|
||||
});
|
||||
await expect(page.locator(".login-account-row")).toHaveCount(1);
|
||||
await expect(page.locator('[data-switch-id]')).toHaveCount(0);
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=reviewWorkspaceView/);
|
||||
});
|
||||
|
||||
test("the return control also restores the originating workspace page", async ({ page }) => {
|
||||
await openPicker(page, { next: "/index.html?view=ladderView" });
|
||||
await page.locator('[data-login-action="resume"]').click();
|
||||
await expect(page).toHaveURL(/view=ladderView/);
|
||||
});
|
||||
|
||||
test("refreshing the picker still returns to the originating page", async ({ page }) => {
|
||||
await openPicker(page, { next: "/index.html?view=sentimentCycleView" });
|
||||
await page.reload();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=sentimentCycleView/);
|
||||
});
|
||||
|
||||
test("an expired current session asks for login instead of pretending to return", async ({ page }) => {
|
||||
await openPicker(page, {
|
||||
next: "/index.html?view=sentimentCycleView",
|
||||
sessionExpired: true,
|
||||
});
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page.locator(".login-error")).toHaveText("当前会话已失效,请重新登录");
|
||||
await expect(page).toHaveURL(/\/login\/?/);
|
||||
});
|
||||
|
||||
test("other saved accounts still switch while the current row only resumes", async ({ page }) => {
|
||||
await openPicker(page, { next: "/index.html?view=auctionView" });
|
||||
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("workspace switch-account menu carries the current page back to the picker", async ({ page }) => {
|
||||
await mockLoginPortal(page, {
|
||||
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
|
||||
currentUserId: 1,
|
||||
});
|
||||
await page.goto("/index.html?view=sentimentCycleView");
|
||||
await expect(page.locator("#accountButton")).toBeVisible();
|
||||
await page.locator("#accountButton").click();
|
||||
await page.locator("#switchAccountMenuButton").click();
|
||||
await expect(page).toHaveURL(/\/login\/\?next=/);
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=sentimentCycleView/);
|
||||
});
|
||||
|
||||
test("workspace switch-account from a second page also returns to that page", async ({ page }) => {
|
||||
await mockLoginPortal(page, {
|
||||
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
|
||||
currentUserId: 1,
|
||||
});
|
||||
await page.goto("/index.html?view=ladderView");
|
||||
await expect(page.locator("#accountButton")).toBeVisible();
|
||||
await page.locator("#accountButton").click();
|
||||
await page.locator("#switchAccountMenuButton").click();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=ladderView/);
|
||||
});
|
||||
|
||||
@@ -46,9 +46,22 @@ class LoginPortalContractTests(unittest.TestCase):
|
||||
|
||||
def test_portal_keeps_account_switch_and_theme_hooks(self) -> None:
|
||||
self.assertIn("data-switch-id", LOGIN_JS)
|
||||
self.assertIn("data-resume-id", LOGIN_JS)
|
||||
self.assertIn('data-login-action="manage"', LOGIN_JS)
|
||||
self.assertIn('data-login-action="add"', LOGIN_JS)
|
||||
self.assertIn('data-login-action="resume"', LOGIN_JS)
|
||||
self.assertIn("继续使用", LOGIN_JS)
|
||||
self.assertIn("返回复盘", LOGIN_JS)
|
||||
self.assertIn("/api/auth/me", LOGIN_JS)
|
||||
self.assertIn("xiaobaiTheme", LOGIN_JS)
|
||||
self.assertNotIn("内网个人版", LOGIN)
|
||||
self.assertNotIn("192.168.200.11", LOGIN)
|
||||
self.assertNotIn("/api/heaven", LOGIN_JS)
|
||||
|
||||
def test_current_account_row_stays_clickable_without_reswitching(self) -> None:
|
||||
self.assertIn("resumeCurrentAccount", LOGIN_JS)
|
||||
self.assertIn("当前会话已失效,请重新登录", LOGIN_JS)
|
||||
self.assertNotIn("!managing && !current ? \"is-switchable\"", LOGIN_JS)
|
||||
session = (ROOT / "frontend" / "shared" / "session.js").read_text(encoding="utf-8")
|
||||
self.assertIn('params.set("next", next)', session)
|
||||
self.assertIn(".login-account-action", AUTH)
|
||||
|
||||
Reference in New Issue
Block a user