From f905b44675f292ab7235f727a53389f9fb71be81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sat, 29 Aug 2026 19:21:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(HEL-240):=20=E6=8C=89=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=A0=B7=E5=9B=BE=E8=BF=98=E5=8E=9F=E6=A1=8C=E9=9D=A2=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E5=B7=A6=E5=8F=B3=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把登录门户改回确认稿的品牌行、中下部主标题、底部行情标签和 K 线背景,并让超宽屏按比例展开左栏。 Co-authored-by: Cursor Co-authored-by: multica-agent --- config/architecture-inventory.json | 6 +- frontend/index.html | 4 +- frontend/login/index.html | 86 ++++-- frontend/login/page.js | 85 ++++-- frontend/shared/auth.css | 457 ++++++++++++++++++++++++----- frontend/shared/tokens.css | 22 ++ tests/e2e/login-portal.spec.js | 139 +++++++++ tests/test_login_portal.py | 51 ++++ 8 files changed, 734 insertions(+), 116 deletions(-) create mode 100644 tests/test_login_portal.py diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 9a12bbe..683c806 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -374,10 +374,10 @@ } ], "css_layers": [ - "/shared/tokens.css?v=20260829-1", + "/shared/tokens.css?v=20260829-hel240", "/shared/base.css?v=20260806-1", "/shared/shell.css?v=20260829-hel237", - "/shared/auth.css?v=20260829-hel237", + "/shared/auth.css?v=20260829-hel240", "/shared/components/controls.css?v=20260829-hel237", "/shared/components/navigation.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1", @@ -465,7 +465,7 @@ }, { "path": "frontend/index.html", - "bytes": 48248, + "bytes": 48253, "lines": 664 }, { diff --git a/frontend/index.html b/frontend/index.html index 73e4d90..d42f10e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -34,10 +34,10 @@ document.documentElement.style.colorScheme = theme; })(); - + - + diff --git a/frontend/login/index.html b/frontend/login/index.html index 4542933..3655bb3 100644 --- a/frontend/login/index.html +++ b/frontend/login/index.html @@ -19,41 +19,77 @@ document.documentElement.style.colorScheme = theme; })(); - + - + -
+
- + diff --git a/frontend/login/page.js b/frontend/login/page.js index 196b478..9c2617e 100644 --- a/frontend/login/page.js +++ b/frontend/login/page.js @@ -13,6 +13,8 @@ loading: false, confirmingId: null, error: "", + username: "", + password: "", }; function escapeHtml(value) { @@ -52,9 +54,27 @@ state.error = message || ""; } - function membershipLabel(account) { - if (account.role === "admin") return account.membership?.subscribed ? "管理员 · 会员" : "管理员"; - return account.membership?.subscribed ? "会员" : "普通用户"; + function formatLastUsed(value) { + if (!value) return ""; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ""; + const now = new Date(); + const hh = String(parsed.getHours()).padStart(2, "0"); + const mm = String(parsed.getMinutes()).padStart(2, "0"); + if (parsed.toDateString() === now.toDateString()) return `今天 ${hh}:${mm}`; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (parsed.toDateString() === yesterday.toDateString()) return `昨天 ${hh}:${mm}`; + return `${parsed.getMonth() + 1}月${parsed.getDate()}日`; + } + + function chipsFor(account, current) { + const chips = []; + if (current) chips.push(''); + if (account.role === "admin") chips.push(''); + if (account.membership?.subscribed) chips.push(''); + else if (account.role !== "admin") chips.push(''); + return chips.join(""); } function enterApp() { @@ -66,34 +86,43 @@ const registering = state.mode === "register"; const submitLabel = options.submitLabel || (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录"); + const lead = options.lead; + const hint = options.hint; + const invalid = state.error ? " is-invalid" : ""; return [ options.back ? '' : "", ``, - ``, + ``, '", '", - '', + ``, ].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) { + const managing = state.view === "manage"; + const classes = [ + "login-account-row", + current ? "is-current" : "", + confirming ? "is-confirming" : "", + !managing && !current ? "is-switchable" : "", + ].filter(Boolean).join(" "); + if (managing && confirming) { return [ `
`, ``, @@ -103,16 +132,24 @@ "
", ].join(""); } - const action = state.view === "manage" - ? `` + const glyph = escapeHtml(String(account.username || "账").slice(0, 1)); + const tone = Number(account.user_id || 0) % 4; + const used = formatLastUsed(account.last_used_at); + const action = managing + ? `` : current ? '' - : ``; + : ""; + const switchAttr = !managing && !current ? ` data-switch-id="${account.user_id}"` : ""; return [ - `
`, + `
`, + ``, '", action, "
", @@ -124,9 +161,11 @@ const managing = state.view === "manage"; return [ ``, - ``, + ``, managing - ? '' + ? '' : "", ``, managing @@ -136,7 +175,9 @@ ? "" : '', state.error ? `` : "", - '', + managing + ? '' + : '', ].join(""); } @@ -145,7 +186,10 @@ if (state.view === "first" || state.view === "add") { card.innerHTML = formMarkup({ title: state.view === "add" ? "添加账号" : "欢迎回来", - lead: "登录后进入你的复盘空间", + lead: state.view === "add" ? "登录另一个账号,添加后可随时一键切换" : "登录后进入你的复盘空间", + hint: state.view === "add" + ? "添加后账号会保存在这台电脑,方便随时切换。" + : "密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。", add: state.view === "add", back: state.view === "add", }); @@ -185,7 +229,8 @@ button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId))); }); card.querySelectorAll("[data-confirm-id]").forEach((button) => { - button.addEventListener("click", () => { + button.addEventListener("click", (event) => { + event.stopPropagation(); state.confirmingId = Number(button.dataset.confirmId); render(); }); @@ -212,6 +257,8 @@ event.preventDefault(); const username = document.querySelector("#loginUsername").value.trim(); const password = document.querySelector("#loginPassword").value; + state.username = username; + state.password = password; if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) { setError("两次输入的密码不一致。"); render(); diff --git a/frontend/shared/auth.css b/frontend/shared/auth.css index c98f15c..88321da 100644 --- a/frontend/shared/auth.css +++ b/frontend/shared/auth.css @@ -1694,6 +1694,7 @@ button.account-role-badge:focus-visible { } } + .login-portal { min-height: 100vh; @@ -1704,8 +1705,20 @@ button.account-role-badge:focus-visible { color: var(--text-primary); } +.login-stage { + position: relative; + + flex: 1 1 auto; + + display: grid; + + place-items: center; + + padding: 48px 24px; +} + .login-theme-toggle { - position: fixed; + position: absolute; z-index: 2; @@ -1731,81 +1744,221 @@ button.account-role-badge:focus-visible { } .login-brand { - width: clamp(420px, 34vw, 560px); + position: relative; - flex: 0 0 auto; + display: flex; - padding: 44px 48px 36px; + flex-direction: column; + + width: var(--login-brand-share); + + min-width: var(--login-brand-min); + + max-width: var(--login-brand-cap); + + flex: 0 0 var(--login-brand-share); + + overflow: hidden; + + padding: var(--login-brand-pad); background: var(--login-brand-gradient); color: #f4f7ff; } +.login-brand-header { + display: flex; + + align-items: center; + + gap: 12px; +} + .login-brand-mark { display: flex; + flex: 0 0 var(--login-brand-mark-size); + align-items: center; justify-content: center; - width: 56px; + width: var(--login-brand-mark-size); - height: 56px; + height: var(--login-brand-mark-size); - border-radius: 16px; + border-radius: 12px; background: rgba(255, 255, 255, 0.12); } .login-brand-glyph { - font-size: 24px; + font-size: 20px; font-weight: var(--font-weight-semibold); } +.login-brand-identity { + display: grid; + + gap: 2px; +} + +.login-brand-name { + margin: 0; + + font-size: var(--login-brand-name-size); + + font-weight: var(--font-weight-semibold); +} + +.login-brand-subtitle { + margin: 0; + + font-size: var(--font-size-caption); + + letter-spacing: 0.02em; + + opacity: 0.72; +} + +.login-brand-copy { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + justify-content: flex-end; + + padding: 24px 0 20px; +} + .login-brand-kicker { - margin: 28px 0 8px; + display: inline-flex; + + align-self: flex-start; + + margin: 0 0 14px; + + padding: 4px 10px; + + border: 1px solid rgba(255, 255, 255, 0.18); + + border-radius: 999px; font-size: var(--font-size-caption); letter-spacing: 0.08em; - opacity: 0.78; + opacity: 0.86; } .login-brand-title { margin: 0; - font-size: 36px; + max-width: 12.4em; + + font-size: var(--login-hero-size); font-weight: var(--font-weight-semibold); + + line-height: 1.35; } .login-brand-lead { margin: 12px 0 0; - max-width: 18em; + max-width: 28em; - font-size: var(--font-size-body); + font-size: var(--font-size-label); line-height: 1.7; - opacity: 0.86; + opacity: 0.78; +} + +.login-brand-market { + position: relative; + + min-height: 168px; +} + +.login-brand-chart { + display: block; + + width: 100%; + + height: 168px; +} + +.login-chart-area { + fill: url(#loginChartFade); +} + +.login-chart-line { + fill: none; + + stroke: var(--login-trend-line); + + stroke-width: 2; + + stroke-linecap: round; + + stroke-linejoin: round; +} + +.login-candles line { + stroke-width: 1.5; +} + +.login-candles .is-up line, +.login-candles .is-up rect { + fill: var(--login-candle-up); + + stroke: var(--login-candle-up); +} + +.login-candles .is-down line, +.login-candles .is-down rect { + fill: var(--login-candle-down); + + stroke: var(--login-candle-down); } .login-brand-stats { - display: grid; + position: absolute; - grid-template-columns: 1fr 1fr; + right: 0; - gap: 16px 20px; + bottom: 18px; - margin: 40px 0 0; + left: 0; + + display: flex; + + flex-wrap: wrap; + + gap: 8px; + + margin: 0; } .login-stat { + display: flex; + + align-items: baseline; + + gap: 6px; + margin: 0; + + padding: 6px 10px; + + border-radius: 999px; + + background: var(--login-stat-chip-bg); } .login-stat dt { @@ -1815,37 +1968,49 @@ button.account-role-badge:focus-visible { } .login-stat dd { - margin: 4px 0 0; + margin: 0; - font-size: 20px; + font-size: var(--font-size-body); font-weight: var(--font-weight-semibold); } +.login-stat:nth-child(1) dd { + color: #f0b45a; +} + +.login-stat:nth-child(2) dd { + color: var(--login-candle-up); +} + +.login-stat:nth-child(3) dd { + color: var(--login-candle-down); +} + .login-stat-tag { - margin-left: 6px; + margin-left: 4px; font-size: var(--font-size-caption); font-weight: var(--font-weight-regular); } -.login-stage { - flex: 1 1 auto; +.login-brand-disclaimer { + margin: 12px 0 0; - display: grid; + font-size: var(--font-size-aux); - place-items: center; + letter-spacing: 0.02em; - padding: 48px 24px; + opacity: 0.48; } .login-card { - width: 400px; + width: var(--login-card-width); max-width: calc(100vw - 48px); - padding: 32px; + padding: var(--login-card-pad); border: 1px solid var(--border-strong); @@ -1865,7 +2030,7 @@ button.account-role-badge:focus-visible { .login-card-title { margin: 0; - font-size: 22px; + font-size: var(--login-card-title-size); font-weight: var(--font-weight-semibold); } @@ -1920,16 +2085,14 @@ button.account-role-badge:focus-visible { margin-top: 20px; } -.login-portal .form-field input { - height: 38px; +.login-portal .form-field input.is-invalid { + border-color: var(--danger); } .login-submit { width: 100%; - height: 40px; - - min-height: 40px; + min-height: var(--login-submit-height); } .login-spinner { @@ -1988,10 +2151,7 @@ button.account-role-badge:focus-visible { } .login-back, -.login-add, -.login-manage, -.login-account-enter, -.login-account-remove { +.login-manage { border: 0; background: transparent; @@ -2010,15 +2170,27 @@ button.account-role-badge:focus-visible { } .login-portal .login-add { - display: block; + display: flex; + + align-items: center; + + justify-content: center; width: 100%; - min-height: 40px; + min-height: 44px; - margin-top: 8px; + margin-top: 12px; - text-align: left; + border: 1px dashed var(--border-strong); + + border-radius: var(--size-radius-md); + + background: transparent; + + color: var(--action); + + cursor: pointer; } .login-portal .login-manage { @@ -2026,33 +2198,67 @@ button.account-role-badge:focus-visible { width: 100%; - min-height: 40px; + min-height: 36px; margin-top: 8px; - text-align: left; + padding: 0; + + text-align: center; color: var(--text-secondary); } -.login-account-list { - display: grid; - - gap: 8px; - - margin-top: 20px; -} - -.login-account-row { +.login-manage-toolbar { display: flex; align-items: center; justify-content: space-between; - min-height: 58px; + gap: 12px; - padding: 0 14px; + margin-top: 16px; +} + +.login-manage-hint { + margin: 0; + + color: var(--text-tertiary); + + font-size: var(--font-size-caption); +} + +.login-manage-done { + border: 0; + + background: transparent; + + color: var(--action); + + cursor: pointer; +} + +.login-account-list { + display: grid; + + gap: 10px; + + margin-top: 20px; +} + +.login-account-row { + display: grid; + + grid-template-columns: auto minmax(0, 1fr) auto; + + align-items: center; + + gap: 12px; + + min-height: var(--login-account-row-min); + + padding: 10px 14px; border: 1px solid var(--border); @@ -2061,6 +2267,10 @@ button.account-role-badge:focus-visible { background: var(--surface); } +.login-account-row.is-switchable { + cursor: pointer; +} + .login-account-row.is-current { border-color: var(--action); } @@ -2068,33 +2278,135 @@ button.account-role-badge:focus-visible { .login-account-row.is-confirming { display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; padding: 12px 14px; + + border-color: var(--danger); +} + +.login-avatar { + display: grid; + + place-items: center; + + width: var(--login-account-avatar); + + height: var(--login-account-avatar); + + border-radius: 50%; + + color: #fff; + + font-size: var(--font-size-body); + + font-weight: var(--font-weight-semibold); +} + +.login-avatar.tone-0 { + background: var(--action); +} + +.login-avatar.tone-1 { + background: var(--market-up); +} + +.login-avatar.tone-2 { + background: var(--market-down); +} + +.login-avatar.tone-3 { + background: var(--warning); } .login-account-meta { display: grid; - gap: 2px; + gap: 4px; + + min-width: 0; +} + +.login-account-name { + display: flex; + + flex-wrap: wrap; + + align-items: center; + + gap: 6px; } .login-account-meta strong { font-weight: var(--font-weight-medium); } -.login-account-meta span { +.login-account-used { color: var(--text-tertiary); font-size: var(--font-size-caption); } +.login-chip { + display: inline-flex; + + align-items: center; + + min-height: 18px; + + padding: 0 6px; + + border-radius: 999px; + + background: var(--surface-muted); + + color: var(--text-secondary); + + font-size: var(--font-size-aux); +} + +.login-chip-current { + background: var(--action-soft); + + color: var(--action); +} + +.login-chip-member { + background: var(--warning-soft); + + color: var(--warning); +} + .login-account-check { color: var(--action); font-size: 16px; } +.login-account-remove { + display: grid; + + place-items: center; + + width: 28px; + + height: 28px; + + border: 0; + + background: transparent; + + color: var(--text-tertiary); + + cursor: pointer; +} + +.login-account-remove:hover { + color: var(--danger); +} + .login-confirm-copy { margin: 0; @@ -2107,6 +2419,18 @@ button.account-role-badge:focus-visible { gap: 8px; } +@media (min-width: 1921px) { + .login-brand { + width: var(--login-brand-wide-share); + + min-width: var(--login-brand-cap); + + max-width: none; + + flex-basis: var(--login-brand-wide-share); + } +} + @media (max-width: 900px) { .login-portal { flex-direction: column; @@ -2115,22 +2439,21 @@ button.account-role-badge:focus-visible { .login-brand { width: 100%; + min-width: 0; + + max-width: none; + + flex-basis: auto; + padding: 20px 20px 16px; } - .login-brand-lead, - .login-brand-stats { + .login-brand-copy, + .login-brand-market, + .login-brand-disclaimer { 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/tokens.css b/frontend/shared/tokens.css index 3102d33..ba04e4e 100644 --- a/frontend/shared/tokens.css +++ b/frontend/shared/tokens.css @@ -147,6 +147,24 @@ --duration-fast: 150ms; --duration-normal: 220ms; --login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc); + --login-brand-share: 34%; + --login-brand-min: 420px; + --login-brand-cap: 560px; + --login-brand-wide-share: 29.2%; + --login-brand-pad: 36px 40px 24px; + --login-brand-mark-size: 44px; + --login-brand-name-size: 16px; + --login-hero-size: 28px; + --login-card-width: 408px; + --login-card-pad: 32px; + --login-card-title-size: 22px; + --login-account-row-min: 72px; + --login-account-avatar: 40px; + --login-submit-height: 40px; + --login-stat-chip-bg: rgba(8, 12, 24, 0.48); + --login-candle-up: #e07078; + --login-candle-down: #3db88a; + --login-trend-line: rgba(244, 247, 255, 0.88); --font-size-aux: 11.5px; --font-size-caption: 12.5px; @@ -516,6 +534,10 @@ --warning-line-strong: #66502d; --control-shadow: 0 1px 3px rgba(0, 0, 0, .3); --login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a); + --login-stat-chip-bg: rgba(6, 8, 16, 0.58); + --login-candle-up: #f06d73; + --login-candle-down: #43bc8a; + --login-trend-line: rgba(232, 236, 244, 0.9); --dialog-backdrop: var(--backdrop); --ladder-level-1: #2d2426; --ladder-level-2: #2b2822; diff --git a/tests/e2e/login-portal.spec.js b/tests/e2e/login-portal.spec.js index b31b913..3f98967 100644 --- a/tests/e2e/login-portal.spec.js +++ b/tests/e2e/login-portal.spec.js @@ -1,4 +1,9 @@ const { test, expect } = require("@playwright/test"); +const fs = require("fs"); +const path = require("path"); + +const SHOT_DIR = path.resolve(__dirname, "../../../verify-shots"); +fs.mkdirSync(SHOT_DIR, { recursive: true }); function loginPayload(user) { return { @@ -54,6 +59,15 @@ async function mockLoginPortal(page, options = {}) { return; } if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") { + if (options.loginDelay) await new Promise((resolve) => setTimeout(resolve, options.loginDelay)); + if (options.loginFails) { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "账号名或密码不正确,请重新输入。" }), + }); + return; + } await route.fulfill({ status: 200, contentType: "application/json", @@ -149,3 +163,128 @@ test("managing accounts removes a local record after inline confirmation", async await expect(page.locator(".login-account-row")).toHaveCount(1); await expect(page.locator(".login-account-row")).toContainText("alpha_user"); }); + +async function assertConfirmedSkeleton(page, { width, height }) { + await expect(page.locator(".login-brand-title")).toHaveText("看懂情绪周期,把复盘变成下一次的先手。"); + await expect(page.locator(".login-brand-header")).toBeVisible(); + await expect(page.locator(".login-brand-chart")).toBeVisible(); + await expect(page.locator(".login-brand-stats")).toBeVisible(); + await expect(page.locator(".login-brand-kicker")).toHaveText("收盘之后 · 复盘开始"); + const brand = await page.locator(".login-brand").boundingBox(); + const header = await page.locator(".login-brand-header").boundingBox(); + const mark = await page.locator(".login-brand-mark").boundingBox(); + const name = await page.locator(".login-brand-name").boundingBox(); + const title = await page.locator(".login-brand-title").boundingBox(); + const stats = await page.locator(".login-brand-stats").boundingBox(); + const chart = await page.locator(".login-brand-chart").boundingBox(); + const card = await page.locator(".login-card").boundingBox(); + expect(brand).toBeTruthy(); + expect(header.y - brand.y).toBeLessThan(48); + expect(Math.abs(mark.y - name.y)).toBeLessThan(16); + expect(title.y).toBeGreaterThan(height * 0.28); + expect(title.y).toBeLessThan(height * 0.72); + expect(stats.y).toBeGreaterThan(height * 0.55); + expect(chart.height).toBeGreaterThan(80); + expect(card.width).toBeGreaterThan(380); + expect(card.width).toBeLessThan(450); + if (width === 1440) { + expect(brand.width).toBeGreaterThan(470); + expect(brand.width).toBeLessThan(520); + } else if (width === 1920) { + expect(brand.width).toBeGreaterThan(540); + expect(brand.width).toBeLessThan(580); + } else { + expect(brand.width).toBeGreaterThan(560); + } +} + +async function openPortal(page, { theme, width, height, accounts, currentUserId, loginFails, loginDelay }) { + await page.addInitScript((nextTheme) => { + localStorage.setItem("xiaobaiTheme", nextTheme); + }, theme); + await page.setViewportSize({ width, height }); + await mockLoginPortal(page, { accounts, currentUserId, loginFails, loginDelay }); + await page.goto("/login/"); +} + +for (const theme of ["light", "dark"]) { + for (const [width, height] of [[1440, 900], [1920, 1080]]) { + test(`confirmed skeleton ${theme} ${width}x${height}`, async ({ page }) => { + await openPortal(page, { theme, width, height, accounts: [] }); + await assertConfirmedSkeleton(page, { width, height }); + await expect(page.locator("#loginThemeToggle")).toHaveText(theme === "dark" ? "☀ 日间" : "🌙 夜间"); + await page.screenshot({ path: path.join(SHOT_DIR, `first-${theme}-${width}.png`), fullPage: true }); + }); + } +} + +test("ultrawide keeps the left brand from collapsing into a strip", async ({ page }) => { + await openPortal(page, { theme: "dark", width: 2560, height: 1080, accounts: [] }); + await assertConfirmedSkeleton(page, { width: 2560, height: 1080 }); +}); + +test("picker add remove error and loading share the same desktop skeleton", async ({ page }) => { + const accounts = SAVED_ACCOUNTS.map((item) => ({ ...item })); + await openPortal(page, { + theme: "dark", + width: 1440, + height: 900, + accounts, + currentUserId: 1, + }); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await expect(page.locator(".login-card-title")).toHaveText("选择账号"); + await expect(page.locator(".login-avatar")).toHaveCount(2); + await expect(page.locator(".login-add")).toBeVisible(); + await page.screenshot({ path: path.join(SHOT_DIR, "picker-dark-1440.png"), fullPage: true }); + + await page.locator('[data-login-action="add"]').click(); + await expect(page.locator(".login-card-title")).toHaveText("添加账号"); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await page.screenshot({ path: path.join(SHOT_DIR, "add-dark-1440.png"), fullPage: true }); + + await page.locator('[data-login-action="picker"]').click(); + 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.screenshot({ path: path.join(SHOT_DIR, "remove-dark-1440.png"), fullPage: true }); +}); + +test("login failure and loading keep the confirmed first-login skeleton", async ({ page }) => { + await openPortal(page, { + theme: "light", + width: 1440, + height: 900, + accounts: [], + loginFails: true, + }); + await page.locator("#loginUsername").fill("baiqizhi"); + await page.locator("#loginPassword").fill("wrong-password"); + await page.locator(".login-submit").click(); + await expect(page.locator(".login-error")).toContainText("账号名或密码不正确"); + await expect(page.locator("#loginPassword")).toHaveClass(/is-invalid/); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await page.screenshot({ path: path.join(SHOT_DIR, "error-light-1440.png"), fullPage: true }); +}); + +test("loading button appears on the confirmed first-login skeleton", async ({ page }) => { + await openPortal(page, { + theme: "dark", + width: 1440, + height: 900, + accounts: [], + loginDelay: 2500, + }); + await page.evaluate(() => { + window.location.replace = () => {}; + }); + await page.locator("#loginUsername").fill("baiqizhi"); + await page.locator("#loginPassword").fill("password12"); + const submit = page.locator(".login-submit").click(); + await expect(page.locator(".login-submit")).toContainText("正在登录..."); + await expect(page.locator(".login-spinner")).toBeVisible(); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true }); + await submit; +}); diff --git a/tests/test_login_portal.py b/tests/test_login_portal.py new file mode 100644 index 0000000..06cd595 --- /dev/null +++ b/tests/test_login_portal.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LOGIN = (ROOT / "frontend" / "login" / "index.html").read_text(encoding="utf-8") +LOGIN_JS = (ROOT / "frontend" / "login" / "page.js").read_text(encoding="utf-8") +AUTH = (ROOT / "frontend" / "shared" / "auth.css").read_text(encoding="utf-8") +TOKENS = (ROOT / "frontend" / "shared" / "tokens.css").read_text(encoding="utf-8") + + +class LoginPortalContractTests(unittest.TestCase): + def test_confirmed_brand_skeleton_is_in_markup(self) -> None: + for needle in ( + 'class="login-brand-header"', + 'class="login-brand-name"', + "A股个人复盘工作台", + 'class="login-brand-kicker"', + "看懂情绪周期,把复盘变成下一次的先手。", + 'class="login-brand-chart"', + 'class="login-brand-stats"', + "股市有风险,投资需谨慎", + 'id="loginThemeToggle"', + 'id="loginCard"', + ): + self.assertIn(needle, LOGIN) + + def test_login_tokens_own_the_confirmed_layout_metrics(self) -> None: + for needle in ( + "--login-brand-share: 34%;", + "--login-brand-cap: 560px;", + "--login-brand-wide-share: 29.2%;", + "--login-card-width: 408px;", + "--login-hero-size: 28px;", + "--login-account-row-min: 72px;", + ): + self.assertIn(needle, TOKENS) + self.assertIn("width: var(--login-brand-share);", AUTH) + self.assertIn("width: var(--login-card-width);", AUTH) + self.assertIn("justify-content: flex-end;", AUTH) + + def test_portal_keeps_account_switch_and_theme_hooks(self) -> None: + self.assertIn("data-switch-id", LOGIN_JS) + self.assertIn('data-login-action="manage"', LOGIN_JS) + self.assertIn('data-login-action="add"', LOGIN_JS) + self.assertIn("xiaobaiTheme", LOGIN_JS) + self.assertNotIn("内网个人版", LOGIN) + self.assertNotIn("192.168.200.11", LOGIN) + self.assertNotIn("/api/heaven", LOGIN_JS)