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()
+17 -10
View File
@@ -11,19 +11,31 @@
<!--
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
STORY: 居中集团登录卡:先确认集团身份系统名称,再选择工作端口登录。
FIRST VIEWPORT: 一张悬浮玻璃登录卡居于氛围光中央,集团名称置顶,端口选择、账号表单与系统事实依次排列
STORY: 桌面双区入口:左侧确认集团身份系统名称与账期状态,右侧选择工作端口登录。
FIRST VIEWPORT: 横向双区;左侧入口信息,右侧 420-460px 登录表单。端口选择为紧凑分段控件
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<main class="entry-shell">
<section class="entry-card" aria-labelledby="login-title">
<section class="entry-context" aria-labelledby="product-name">
<div class="entry-brand"><span class="brand-mark"></span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div>
<p class="entry-status"><span>服务状态</span><b>运行中</b></p>
<div class="entry-statement">
<h1>一笔往来,追溯到双方银行凭证。</h1>
<p>选择与账号一致的工作端口后进入对应工作台。总账管理端查看集团全貌,公司业务端只处理本公司账务。</p>
</div>
<dl class="entry-facts">
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
<div><dt>覆盖银行</dt><dd>已对接 6 家</dd></div>
</dl>
</section>
<section class="entry-form-wrap" aria-labelledby="login-title">
<form class="entry-form" id="loginForm">
<header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header>
<div class="role-switch" role="radiogroup" aria-label="工作端口">
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label>
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label>
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small class="sr-only">集团管理员</small></span></label>
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small class="sr-only">公司出纳</small></span></label>
</div>
<label class="field"><span>账号</span><input name="username" autocomplete="username" required /></label>
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
@@ -36,11 +48,6 @@
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
</form>
<dl class="entry-facts">
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
<div><dt>覆盖银行</dt><dd>已对接 6 家</dd></div>
</dl>
</section>
</main>
<script src="app.js"></script>
+50 -51
View File
@@ -435,32 +435,59 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
/* Login */
.entry-page { overflow-x: hidden; }
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #080808; }
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); }
.entry-brand { display: flex; align-items: center; gap: 12px; }
.entry-brand > span:last-child { display: flex; flex-direction: column; }
.entry-shell { width: 100%; max-width: none; min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr) minmax(460px, 520px); margin: 0; padding: 0; }
.entry-context { position: relative; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(28px, 4vw, 56px) clamp(28px, 4vw, 64px); border-right: 1px solid var(--color-line); background: var(--color-bg-soft); }
.entry-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.entry-brand > span:last-child { display: flex; flex-direction: column; min-width: 0; }
.entry-brand strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-brand small { color: var(--color-ink-muted); }
.entry-statement { max-width: 680px; margin: 80px 0; }
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; }
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); }
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; }
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.entry-facts dt { color: var(--color-ink-muted); }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); }
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); }
.entry-form { width: min(420px, 100%); display: grid; gap: 18px; padding: 28px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: var(--color-surface); box-shadow: var(--shadow-panel); backdrop-filter: blur(24px); }
.entry-form header h2 { font-size: 25px; }
.entry-status { display: flex; align-items: center; gap: 8px; margin: 14px 0 0; color: var(--color-ink-soft); font-size: 12px; }
.entry-status span { color: var(--color-ink-muted); }
.entry-status b { color: var(--color-ink); font-weight: 700; }
.entry-status b::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--color-primary); vertical-align: 1px; }
.entry-statement { max-width: 640px; margin: 48px 0 auto; }
.entry-statement h1 { max-width: 620px; font-size: clamp(30px, 3.4vw, 48px); line-height: 1.18; letter-spacing: 0; }
.entry-statement p { margin-top: 12px; color: var(--color-ink-muted); max-width: 520px; }
.entry-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; max-width: 640px; }
.entry-facts div { min-width: 0; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface-muted); }
.entry-facts dt { color: var(--color-ink-muted); font-size: 11px; }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); overflow-wrap: anywhere; }
.entry-form-wrap { min-width: 0; min-height: 100vh; display: grid; place-items: center; padding: 28px 24px; background: var(--color-bg); }
.entry-form { width: min(440px, 100%); display: grid; gap: 11px; padding: 22px 22px 20px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(18px); }
.entry-form header h2 { font-size: 20px; }
.entry-form header p { margin-top: 4px; font-size: 12px; }
.entry-form header p, .entry-note { color: var(--color-ink-muted); }
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.role-switch label { cursor: pointer; }
.role-switch input { position: absolute; opacity: 0; }
.role-switch span { min-height: 83px; display: grid; grid-template-columns: 30px 1fr; align-content: center; gap: 1px 9px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.14); transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); }
.role-switch small { color: var(--color-ink-muted); }
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); }
.entry-form .field { gap: 4px; }
.role-switch { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
.role-switch label { position: relative; flex: 1; min-width: 0; cursor: pointer; }
.role-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.role-switch span { min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); transition: color var(--duration-fast), background var(--duration-fast); }
.role-switch svg { width: 15px; height: 15px; flex: 0 0 auto; }
.role-switch b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.role-switch input:checked + span { background: var(--color-primary-wash); color: var(--color-ink); }
.role-switch input:checked + span svg { color: var(--color-primary); }
.entry-note { font-size: 10px; }
.role-switch input:focus-visible + span { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
.entry-note { font-size: 12px; }
@media (max-width: 1023px) {
.entry-shell { grid-template-columns: minmax(0, 1fr) minmax(400px, 440px); }
.entry-context { padding: 24px 20px; }
.entry-statement { margin: 28px 0 auto; }
.entry-statement h1 { font-size: clamp(24px, 3.6vw, 34px); }
.entry-facts { grid-template-columns: 1fr; max-width: none; }
.entry-form-wrap { padding: 20px 16px; }
.entry-form { width: min(400px, 100%); padding: 18px; gap: 10px; }
}
@media (max-width: 720px) {
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: auto; padding: 22px 16px 16px; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-statement { margin: 18px 0 14px; }
.entry-statement h1 { font-size: 24px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { min-height: auto; padding: 16px 16px 32px; align-content: start; }
.entry-form { width: 100%; padding: 16px; }
}
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 88px minmax(0, 1fr); }
@@ -497,9 +524,6 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
.subject-strip { grid-template-columns: repeat(3, 1fr); }
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
.work-progress i:nth-of-type(2) { display: none; }
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: 58vh; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-form-wrap { min-height: auto; }
}
@media (max-width: 720px) {
@@ -573,12 +597,6 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
.form-grid { grid-template-columns: 1fr; }
.toast-region { right: 12px; bottom: 12px; left: 12px; }
.toast { min-width: 0; max-width: none; }
.entry-context { min-height: auto; padding: 30px 22px; }
.entry-statement { margin: 54px 0 42px; }
.entry-statement h1 { font-size: 39px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { padding: 32px 16px 48px; }
.entry-form { padding: 22px; }
}
@media (max-width: 460px) {
@@ -586,7 +604,6 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
.metric-copy { min-height: 52px; }
.metric-label { font-size: 11px; }
.metric-value { font-size: 23px; }
.role-switch { grid-template-columns: 1fr; }
}
@media (max-width: 1180px) {
@@ -718,24 +735,6 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event
.table-scroll { padding: 2px 14px 8px; }
.table-summary { margin: 0 14px; }
/* ===== 登录页重做:居中集团登录卡 ===== */
.entry-shell { display: grid; grid-template-columns: 1fr; place-items: center; min-height: 100vh; padding: 48px 20px; }
.entry-card { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: var(--radius-xl); background: linear-gradient(165deg, rgba(36, 36, 36, 0.9) 0%, rgba(16, 16, 16, 0.92) 60%, rgba(10, 10, 10, 0.94) 100%); box-shadow: var(--shadow-panel), 0 0 60px rgba(55, 235, 137, 0.06); backdrop-filter: blur(28px) saturate(125%); }
.entry-card::before { content: ""; position: absolute; inset: 0; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.07), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 42%, transparent 70%); pointer-events: none; }
.entry-card::after { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 2px; background: linear-gradient(90deg, transparent, rgba(55, 235, 137, 0.65), transparent); }
.entry-brand { flex-direction: column; justify-content: center; gap: 15px; min-height: 0; padding: 36px 28px 24px; border-bottom: 1px solid var(--color-line); text-align: center; }
.entry-brand .brand-mark { width: 54px; height: 54px; border-radius: 17px; font-size: 26px; }
.entry-brand > span:last-child { align-items: center; }
.entry-brand strong { font-size: 21px; letter-spacing: 0.03em; }
.entry-brand small { margin-top: 4px; font-size: 12px; letter-spacing: 0.08em; }
.entry-form { width: 100%; gap: 16px; padding: 24px 28px 10px; border: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
.entry-form header { text-align: center; }
.entry-form header h2 { font-size: 22px; }
.entry-form header p { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.entry-facts { max-width: none; gap: 10px; padding: 16px 28px 28px; }
.entry-facts div { padding: 12px; text-align: center; }
.entry-facts dd { margin-top: 2px; font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}