B-229: 登录页改为桌面双区布局并收紧三档响应式

把居中手机卡改回横向双区,端口选择复用分段控件节奏,避免再叠加一套登录样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
腾讯WorkBuddy
2026-08-20 16:40:22 +08:00
co-authored by Cursor
parent 4add17f2b8
commit 79bd32f46e
4 changed files with 364 additions and 61 deletions
+75
View File
@@ -0,0 +1,75 @@
"""Static contracts for the B-229 login desktop layout."""
from __future__ import annotations
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
class LoginFrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = (WEB / "index.html").read_text(encoding="utf-8")
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
def test_dual_zone_markup_and_auth_controls(self) -> None:
self.assertIn('class="entry-context"', self.html)
self.assertIn('class="entry-form-wrap"', self.html)
self.assertIn('id="loginForm"', self.html)
self.assertIn('id="togglePassword"', self.html)
self.assertIn('id="changePassword"', self.html)
self.assertIn('id="loginError"', self.html)
self.assertIn('name="username"', self.html)
self.assertIn('name="password"', self.html)
self.assertIn('name="new_password"', self.html)
self.assertIn('name="confirm_password"', self.html)
self.assertIn('value="admin"', self.html)
self.assertIn('value="company"', self.html)
self.assertIn("总账管理端", self.html)
self.assertIn("公司业务端", self.html)
self.assertIn("服务状态", self.html)
self.assertIn("当前账期", self.html)
self.assertNotIn("entry-card", self.html)
self.assertNotIn('value="group-admin"', self.html)
self.assertNotIn('value="demo123456"', self.html)
def test_login_css_lives_in_one_section(self) -> None:
self.assertIn("minmax(460px, 520px)", self.css)
self.assertIn("width: min(440px, 100%)", self.css)
self.assertIn("@media (max-width: 1023px)", self.css)
self.assertIn("@media (max-width: 720px)", self.css)
self.assertIn("minmax(400px, 440px)", self.css)
self.assertNotIn(".entry-card", self.css)
self.assertNotIn("0 0 60px rgba(55, 235, 137, 0.06)", self.css)
login_block = self.css.split("/* Login */", 1)[1]
after_login = login_block.split("/* ---- B-44", 1)[0] if "/* ---- B-44" in login_block else login_block
self.assertEqual(1, after_login.count(".entry-shell { width: 100%;"))
self.assertEqual(3, len(re.findall(r"\.entry-shell \{", after_login)))
later = self.css.split("/* ---- B-44 intercompany balances ---- */", 1)[-1]
self.assertNotIn(".entry-shell", later)
self.assertNotIn(".entry-form {", later)
self.assertNotIn(".role-switch {", later)
def test_role_switch_reuses_segmented_rhythm(self) -> None:
self.assertIn(".role-switch { display: flex; gap: 4px; padding: 4px;", self.css)
self.assertIn("min-height: 36px", self.css)
self.assertIn('role="radiogroup"', self.html)
self.assertIn("class=\"sr-only\"", self.html)
def test_auth_javascript_untouched(self) -> None:
self.assertIn("function initEntry()", self.js)
self.assertIn('fetch("/api/login"', self.js)
self.assertIn('fetch("/api/password/change"', self.js)
self.assertIn("must_change_password", self.js)
self.assertIn("#togglePassword", self.js)
self.assertIn("进入总账管理端", self.js)
self.assertIn("进入公司业务端", self.js)
if __name__ == "__main__":
unittest.main()
+222
View File
@@ -0,0 +1,222 @@
"""Chromium layout regression for the B-229 login page.
Measures dual-zone geometry, form width, overflow and overlap at the
accepted viewports. Screenshots are written next to other visual evidence.
"""
from __future__ import annotations
import base64
import json
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_b44_layout import QuietHandler, _Cdp, chrome_bin # noqa: E402
SCREEN_DIR = ROOT / "screenshots"
LOGIN_URL = "/web/index.html"
MEASURE = r"""
(() => {
const box = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
width: r.width, height: r.height
};
};
const overlapArea = (a, b) => {
if (!a || !b) return 0;
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx * dy);
};
const context = document.querySelector(".entry-context");
const wrap = document.querySelector(".entry-form-wrap");
const form = document.querySelector(".entry-form");
const role = document.querySelector(".role-switch");
const spans = [...document.querySelectorAll(".role-switch span")];
const brand = document.querySelector("#product-name");
const status = document.querySelector(".entry-status");
const statement = document.querySelector(".entry-statement h1");
const ctx = box(context);
const wr = box(wrap);
const fm = box(form);
const sp = spans.map(box);
const clipped = (el) => Boolean(el && el.scrollWidth > el.clientWidth + 1);
return {
viewport: window.innerWidth,
viewportH: window.innerHeight,
grid: getComputedStyle(document.querySelector(".entry-shell")).gridTemplateColumns,
contextBox: ctx,
wrapBox: wr,
formBox: fm,
formWidth: fm ? Math.round(fm.width) : 0,
roleHeight: role ? Math.round(role.getBoundingClientRect().height) : 0,
roleHorizontal: sp.length === 2 && sp[0].right <= sp[1].left + 2,
dualZone: Boolean(ctx && wr && ctx.right <= wr.left + 2 && Math.abs(ctx.top - wr.top) < 48),
stacked: Boolean(ctx && wr && ctx.bottom <= wr.top + 8),
overlap: overlapArea(ctx, fm),
pageOverflowX: document.documentElement.scrollWidth - window.innerWidth,
formBottom: fm ? fm.bottom : 0,
formFitsViewport: Boolean(fm && fm.top >= -1 && fm.bottom <= window.innerHeight + 2),
brandClipped: clipped(brand),
statementClipped: clipped(statement),
statusText: (status?.textContent || "").replace(/\s+/g, " ").trim(),
periodText: (document.querySelector(".entry-facts")?.textContent || "").replace(/\s+/g, " ").trim(),
hasToggle: Boolean(document.querySelector("#togglePassword")),
hasError: Boolean(document.querySelector("#loginError")),
changeHidden: document.querySelector("#changePassword")?.hidden === true,
};
})()
"""
VIEWPORTS = (
(1440, 900, "desktop", "b229-login-1440.png"),
(1366, 768, "desktop", "b229-login-1366.png"),
(1024, 768, "desktop", "b229-login-1024.png"),
(768, 1024, "compact", "b229-login-768.png"),
(375, 812, "mobile", "b229-login-375.png"),
)
class LoginLayoutTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.chrome = chrome_bin()
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.http_thread.start()
cls.port = cls.httpd.server_address[1]
cls.tmp = tempfile.TemporaryDirectory(prefix="b229-login-", ignore_cleanup_errors=True)
cls.proc = subprocess.Popen(
[
str(cls.chrome),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--hide-scrollbars",
"--remote-debugging-port=0",
f"--user-data-dir={cls.tmp.name}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(f"http://127.0.0.1:{cls.debug_port}/json/list", timeout=5).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
SCREEN_DIR.mkdir(exist_ok=True)
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except Exception:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "httpd", None):
cls.httpd.shutdown()
cls.httpd.server_close()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _open(self, width: int, height: int) -> dict:
self.cdp.call(
"Emulation.setDeviceMetricsOverride",
{
"width": width,
"height": height,
"deviceScaleFactor": 1,
"mobile": width <= 720,
},
)
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.port}{LOGIN_URL}"})
time.sleep(0.4)
deadline = time.time() + 8
last = None
while time.time() < deadline:
result = self.cdp.call("Runtime.evaluate", {"expression": MEASURE, "returnByValue": True})
last = result.get("result", {}).get("value")
if last and last.get("formWidth"):
return last
time.sleep(0.1)
raise TimeoutError(f"login metrics not ready: {last}")
def _shot(self, name: str) -> None:
raw = self.cdp.call("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})
(SCREEN_DIR / name).write_bytes(base64.b64decode(raw["data"]))
def test_login_viewports(self) -> None:
for width, height, mode, shot in VIEWPORTS:
with self.subTest(width=width, height=height, mode=mode):
metrics = self._open(width, height)
self._shot(shot)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertEqual(0, metrics["overlap"], metrics)
self.assertLessEqual(metrics["pageOverflowX"], 1, metrics)
self.assertFalse(metrics["brandClipped"], metrics)
self.assertFalse(metrics["statementClipped"], metrics)
self.assertTrue(metrics["roleHorizontal"], metrics)
self.assertLessEqual(metrics["roleHeight"], 52, metrics)
self.assertIn("运行中", metrics["statusText"], metrics)
self.assertIn("2026 年 7 月", metrics["periodText"], metrics)
self.assertTrue(metrics["hasToggle"], metrics)
self.assertTrue(metrics["hasError"], metrics)
self.assertTrue(metrics["changeHidden"], metrics)
if mode == "desktop":
self.assertTrue(metrics["dualZone"], metrics)
self.assertFalse(metrics["stacked"], metrics)
self.assertGreaterEqual(metrics["formWidth"], 420, metrics)
self.assertLessEqual(metrics["formWidth"], 460, metrics)
self.assertTrue(metrics["formFitsViewport"], metrics)
elif mode == "compact":
self.assertTrue(metrics["dualZone"], metrics)
self.assertFalse(metrics["stacked"], metrics)
self.assertGreaterEqual(metrics["formWidth"], 360, metrics)
self.assertLessEqual(metrics["formWidth"], 440, metrics)
else:
self.assertTrue(metrics["stacked"], metrics)
self.assertFalse(metrics["dualZone"], metrics)
if __name__ == "__main__":
unittest.main()