Compare commits

..
Author SHA1 Message Date
4a63ccd10f feat(HEL-251): 按确认稿实现登录页动态小K线人物
在桌面登录门户品牌空白带加入红绿小K线角色,支持焦点、密码遮挡、登录反馈和减少动态效果,且不移动原有文案与行情图。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 21:46:05 +08:00
总工 865d8b0516 部署集成(HEL-244): 合入切换账号返回修复 d2a165a 2026-08-29 21:01:12 +08:00
6 changed files with 839 additions and 7 deletions
+70 -3
View File
@@ -19,9 +19,9 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel240">
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel251">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel243">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel251">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
</head>
<body class="login-portal">
@@ -33,6 +33,73 @@
<p class="login-brand-subtitle">A股个人复盘工作台</p>
</div>
</div>
<div class="login-mascots" id="loginMascots" aria-hidden="true" data-mood="idle">
<div class="login-mascots-stage">
<div class="login-mascot is-red">
<div class="login-mascot-breathe">
<div class="login-mascot-lean">
<svg class="login-mascot-figure" viewBox="0 0 96 210" focusable="false">
<ellipse class="login-mascot-shadow" cx="48" cy="200" rx="26" ry="5.5"></ellipse>
<line class="login-mascot-wick" x1="48" y1="10" x2="48" y2="50"></line>
<rect class="login-mascot-body" x="16" y="50" width="64" height="126" rx="14"></rect>
<line class="login-mascot-wick" x1="48" y1="176" x2="48" y2="190"></line>
<g class="login-mascot-face">
<circle class="login-mascot-eye" cx="36" cy="94" r="10"></circle>
<circle class="login-mascot-eye" cx="60" cy="94" r="10"></circle>
<circle class="login-mascot-pupil" cx="36" cy="94" r="4.2"></circle>
<circle class="login-mascot-pupil" cx="60" cy="94" r="4.2"></circle>
<rect class="login-mascot-lid" x="24" y="82" width="48" height="24" rx="10"></rect>
<path class="login-mascot-happy" d="M28 96 Q36 88 44 96"></path>
<path class="login-mascot-happy" d="M52 96 Q60 88 68 96"></path>
<circle class="login-mascot-sad" cx="36" cy="96" r="2.2"></circle>
<circle class="login-mascot-sad" cx="60" cy="96" r="2.2"></circle>
</g>
<ellipse class="login-mascot-arm is-left" cx="16" cy="128" rx="8" ry="5.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="80" cy="128" rx="8" ry="5.5"></ellipse>
<ellipse class="login-mascot-hand is-left" cx="16" cy="128" rx="9" ry="7"></ellipse>
<ellipse class="login-mascot-hand is-right" cx="80" cy="128" rx="9" ry="7"></ellipse>
</svg>
</div>
</div>
</div>
<div class="login-mascot is-green">
<div class="login-mascot-breathe">
<div class="login-mascot-lean">
<div class="login-mascot-figure">
<svg class="login-mascot-front" viewBox="0 0 84 175" focusable="false">
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
<g class="login-mascot-face">
<circle class="login-mascot-eye" cx="32" cy="76" r="8.5"></circle>
<circle class="login-mascot-eye" cx="52" cy="76" r="8.5"></circle>
<circle class="login-mascot-pupil" cx="32" cy="76" r="3.6"></circle>
<circle class="login-mascot-pupil" cx="52" cy="76" r="3.6"></circle>
<rect class="login-mascot-lid" x="22" y="66" width="40" height="20" rx="8"></rect>
<path class="login-mascot-happy" d="M25 78 Q32 71 39 78"></path>
<path class="login-mascot-happy" d="M45 78 Q52 71 59 78"></path>
<circle class="login-mascot-sad" cx="32" cy="78" r="1.9"></circle>
<circle class="login-mascot-sad" cx="52" cy="78" r="1.9"></circle>
</g>
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
</svg>
<svg class="login-mascot-back" viewBox="0 0 84 175" focusable="false">
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
<rect class="login-mascot-slit" x="40.5" y="58" width="3" height="72" rx="1.5"></rect>
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="login-brand-copy">
<p class="login-brand-kicker">收盘之后 · 复盘开始</p>
<h1 class="login-brand-title">看懂情绪周期,把复盘变成下一次的先手。</h1>
@@ -90,6 +157,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-hel243"></script>
<script src="/login/page.js?v=20260829-hel251"></script>
</body>
</html>
+246 -1
View File
@@ -15,6 +15,7 @@
error: "",
username: "",
password: "",
passwordVisible: false,
};
function escapeHtml(value) {
@@ -102,6 +103,8 @@
const lead = options.lead;
const hint = options.hint;
const invalid = state.error ? " is-invalid" : "";
const passwordType = state.passwordVisible ? "text" : "password";
const passwordToggle = state.passwordVisible ? "隐藏" : "显示";
return [
options.back
? '<button class="login-back" type="button" data-login-action="picker">返回账号列表</button>'
@@ -114,7 +117,7 @@
"</div>",
'<form class="login-form" id="loginForm">',
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
`<label class="form-field"><span>密码</span><input id="loginPassword" class="${invalid.trim()}" type="password" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required></label>`,
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
@@ -214,6 +217,7 @@
card.innerHTML = pickerMarkup();
}
bindCard();
if (mascots) mascots.sync();
}
function bindCard() {
@@ -227,6 +231,10 @@
card.querySelectorAll("[data-login-action]").forEach((button) => {
button.addEventListener("click", () => {
const action = button.dataset.loginAction;
if (action === "toggle-password") {
togglePasswordVisible();
return;
}
if (action === "resume") {
resumeCurrentAccount();
return;
@@ -293,11 +301,13 @@
render();
try {
await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
await celebrateLogin();
enterApp();
} catch (error) {
state.loading = false;
setError(error.message || "账号操作失败");
render();
mascots.fail();
}
}
@@ -350,6 +360,241 @@
}
}
function togglePasswordVisible() {
state.passwordVisible = !state.passwordVisible;
const input = document.querySelector("#loginPassword");
const button = document.querySelector(".login-password-toggle");
if (input) {
input.type = state.passwordVisible ? "text" : "password";
input.focus();
}
if (button) {
button.textContent = state.passwordVisible ? "隐藏" : "显示";
button.setAttribute("aria-pressed", state.passwordVisible ? "true" : "false");
button.setAttribute("aria-label", state.passwordVisible ? "隐藏密码" : "显示密码");
}
mascots.sync();
}
function reducedMotion() {
return Boolean(global.matchMedia && global.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
function finePointer() {
return Boolean(global.matchMedia && global.matchMedia("(pointer: fine)").matches);
}
function wait(ms) {
return new Promise((resolve) => global.setTimeout(resolve, ms));
}
async function celebrateLogin() {
mascots.succeed();
if (!reducedMotion()) await wait(720);
}
const mascots = (() => {
const root = document.querySelector("#loginMascots");
const red = root && root.querySelector(".login-mascot.is-red");
const green = root && root.querySelector(".login-mascot.is-green");
const motion = {
pupilX: 0,
pupilY: 0,
targetX: 0,
targetY: 0,
leanRed: 0,
leanGreen: 0,
targetLeanRed: 0,
targetLeanGreen: 0,
};
let mood = "idle";
let locked = "";
let blinkTimer = 0;
let failTimer = 0;
let raf = 0;
function setVars() {
if (!red || !green) return;
const pupilX = `${motion.pupilX.toFixed(2)}px`;
const pupilY = `${motion.pupilY.toFixed(2)}px`;
red.style.setProperty("--pupil-x", pupilX);
red.style.setProperty("--pupil-y", pupilY);
green.style.setProperty("--pupil-x", pupilX);
green.style.setProperty("--pupil-y", pupilY);
red.style.setProperty("--lean", `${motion.leanRed.toFixed(2)}deg`);
green.style.setProperty("--lean", `${motion.leanGreen.toFixed(2)}deg`);
}
function poseFor(next) {
if (next === "account" || next === "busy") {
motion.targetX = 3.2;
motion.targetY = 0.8;
motion.targetLeanRed = 7;
motion.targetLeanGreen = 5.6;
return;
}
if (next === "password") {
motion.targetX = 0;
motion.targetY = 0;
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
return;
}
if (next === "fail") {
motion.targetX = 0;
motion.targetY = 2.8;
motion.targetLeanRed = 8;
motion.targetLeanGreen = 8;
return;
}
if (next === "success") {
motion.targetX = 0;
motion.targetY = 0;
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
return;
}
if (!finePointer()) {
motion.targetX = 2.4;
motion.targetY = 0.4;
motion.targetLeanRed = 4;
motion.targetLeanGreen = 3.2;
return;
}
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
}
function applyMood(next) {
if (!root) return;
const changed = next !== mood;
if (changed) {
mood = next;
root.dataset.mood = next;
poseFor(next);
} else if (next !== "idle") {
poseFor(next);
}
if (reducedMotion()) {
motion.pupilX = motion.targetX;
motion.pupilY = motion.targetY;
motion.leanRed = motion.targetLeanRed;
motion.leanGreen = motion.targetLeanGreen;
setVars();
}
}
function focusedControl() {
const active = document.activeElement;
if (!active || !card.contains(active)) return "";
if (active.classList.contains("login-password-toggle")) return "loginPassword";
return active.id || "";
}
function desiredMood() {
if (locked === "success") return "success";
if (state.loading) return "busy";
const focused = focusedControl();
if (focused === "loginPassword" || focused === "loginPasswordConfirm") return "password";
if (focused === "loginUsername") return "account";
if (locked === "fail") return "fail";
return "idle";
}
function sync() {
applyMood(desiredMood());
}
function succeed() {
locked = "success";
applyMood("success");
}
function fail() {
locked = "fail";
applyMood("fail");
global.clearTimeout(failTimer);
failTimer = global.setTimeout(() => {
if (locked === "fail") locked = "";
sync();
}, 900);
}
function blink() {
if (!root || reducedMotion()) return;
if (mood !== "idle" && mood !== "account" && mood !== "busy") return;
root.classList.remove("is-blinking");
void root.offsetWidth;
root.classList.add("is-blinking");
global.setTimeout(() => root.classList.remove("is-blinking"), 160);
}
function scheduleBlink() {
global.clearTimeout(blinkTimer);
if (reducedMotion()) return;
const waitMs = 4000 + Math.random() * 2000;
blinkTimer = global.setTimeout(() => {
blink();
scheduleBlink();
}, waitMs);
}
function onMouseMove(event) {
if (reducedMotion() || !finePointer()) return;
if (desiredMood() !== "idle") return;
const rect = root.getBoundingClientRect();
const cx = rect.left + rect.width * 0.42;
const cy = rect.top + rect.height * 0.42;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const dist = Math.hypot(dx, dy) || 1;
const cap = 3.5;
motion.targetX = (dx / dist) * Math.min(cap, Math.abs(dx) / 90);
motion.targetY = (dy / dist) * Math.min(cap, Math.abs(dy) / 90);
const tilt = Math.max(-5, Math.min(5, (dx / Math.max(global.innerWidth, 1)) * 10));
motion.targetLeanRed = tilt;
motion.targetLeanGreen = tilt * 0.8;
}
function tick() {
if (!root) return;
if (!reducedMotion()) {
motion.pupilX += (motion.targetX - motion.pupilX) * 0.18;
motion.pupilY += (motion.targetY - motion.pupilY) * 0.18;
motion.leanRed += (motion.targetLeanRed - motion.leanRed) * 0.18;
motion.leanGreen += (motion.targetLeanGreen - motion.leanGreen) * 0.18;
setVars();
} else {
motion.pupilX = motion.targetX;
motion.pupilY = motion.targetY;
motion.leanRed = motion.targetLeanRed;
motion.leanGreen = motion.targetLeanGreen;
setVars();
}
raf = global.requestAnimationFrame(tick);
}
function onFocusChange() {
global.requestAnimationFrame(sync);
}
if (root) {
document.addEventListener("focusin", onFocusChange);
document.addEventListener("focusout", onFocusChange);
global.addEventListener("mousemove", onMouseMove, { passive: true });
if (!reducedMotion()) {
raf = global.requestAnimationFrame(tick);
scheduleBlink();
} else {
poseFor("idle");
setVars();
}
sync();
}
return { sync, succeed, fail };
})();
themeButton.addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
});
+383 -3
View File
@@ -1827,16 +1827,351 @@ body.login-portal {
opacity: 0.72;
}
.login-brand-copy {
.login-mascots {
display: flex;
flex: 1 1 auto;
align-items: center;
min-height: 0;
pointer-events: none;
}
.login-mascots-stage {
display: flex;
align-items: flex-end;
padding-left: calc(var(--login-mascot-height) * 82 / 210);
gap: calc(var(--login-mascot-height) * 10 / 210);
}
.login-mascot {
position: relative;
flex: 0 0 auto;
overflow: visible;
--pupil-x: 0px;
--pupil-y: 0px;
--lean: 0deg;
}
.login-mascot.is-red {
width: calc(var(--login-mascot-height) * 96 / 210);
height: var(--login-mascot-height);
}
.login-mascot.is-green {
width: calc(var(--login-mascot-height) * 84 / 210);
height: calc(var(--login-mascot-height) * 175 / 210);
perspective: 420px;
}
.login-mascot-breathe {
width: 100%;
height: 100%;
transform-origin: 50% 100%;
}
.login-mascots[data-mood="idle"] .login-mascot-breathe {
animation: login-mascot-breathe 3.6s ease-in-out infinite;
}
.login-mascot-lean {
width: 100%;
height: 100%;
transform: rotate(var(--lean));
transform-origin: 50% 88%;
}
.login-mascot.is-red .login-mascot-figure {
display: block;
width: 100%;
height: 100%;
}
.login-mascot.is-green .login-mascot-figure {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 280ms ease-in-out;
}
.login-mascot-front {
display: block;
width: 100%;
height: 100%;
backface-visibility: hidden;
}
.login-mascot-back {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
backface-visibility: hidden;
transform: rotateY(180deg);
}
.login-mascots[data-mood="password"] .login-mascot.is-green .login-mascot-figure {
transform: rotateY(180deg);
}
.login-mascot-body {
fill: var(--login-mascot-red);
}
.login-mascot.is-green .login-mascot-body {
fill: var(--login-mascot-green);
}
.login-mascot-wick {
stroke: var(--login-mascot-red);
stroke-width: 4;
stroke-linecap: round;
}
.login-mascot.is-green .login-mascot-wick {
stroke: var(--login-mascot-green);
stroke-width: 3.5;
}
.login-mascot-eye {
fill: var(--login-mascot-eye);
}
.login-mascot-pupil {
fill: var(--login-mascot-pupil);
transform: translate(var(--pupil-x), var(--pupil-y));
transform-box: fill-box;
transform-origin: center;
}
.login-mascot-lid {
fill: var(--login-mascot-red);
transform: scaleY(0);
transform-box: fill-box;
transform-origin: center;
}
.login-mascot.is-green .login-mascot-lid {
fill: var(--login-mascot-green);
}
.login-mascots.is-blinking .login-mascot-lid {
animation: login-mascot-blink 140ms ease-out;
}
.login-mascot-happy {
fill: none;
stroke: var(--login-mascot-pupil);
stroke-width: 2.4;
stroke-linecap: round;
opacity: 0;
}
.login-mascot-sad {
fill: var(--login-mascot-pupil);
opacity: 0;
}
.login-mascot-arm {
fill: var(--login-mascot-red);
transform-box: fill-box;
transform-origin: center;
transition: transform 250ms cubic-bezier(0.2, 0.9, 0.25, 1.2);
}
.login-mascot.is-green .login-mascot-arm {
fill: var(--login-mascot-green);
}
.login-mascot-hand {
fill: var(--login-mascot-red-shade);
opacity: 0;
transform-box: fill-box;
transform-origin: center;
transition: transform 250ms cubic-bezier(0.2, 0.9, 0.25, 1.2), opacity 180ms ease-out;
}
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-hand {
opacity: 1;
}
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-hand.is-left {
transform: translate(20px, -34px) scale(1.2);
}
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-hand.is-right {
transform: translate(-20px, -34px) scale(1.2);
}
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-arm.is-left {
transform: translate(10px, -18px) rotate(-18deg);
}
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-arm.is-right {
transform: translate(-10px, -18px) rotate(18deg);
}
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-pupil,
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-eye {
opacity: 0;
}
.login-mascot-slit {
fill: var(--login-mascot-green-shade);
}
.login-mascot-shadow {
fill: rgba(6, 10, 24, 0.28);
}
.login-mascots[data-mood="success"] .login-mascot-pupil,
.login-mascots[data-mood="success"] .login-mascot-lid,
.login-mascots[data-mood="fail"] .login-mascot-pupil {
opacity: 0;
}
.login-mascots[data-mood="success"] .login-mascot-happy,
.login-mascots[data-mood="fail"] .login-mascot-sad {
opacity: 1;
}
.login-mascots[data-mood="success"] .login-mascot.is-red .login-mascot-arm.is-left {
transform: translate(-2px, -36px) rotate(-48deg);
}
.login-mascots[data-mood="success"] .login-mascot.is-red .login-mascot-arm.is-right {
transform: translate(2px, -36px) rotate(48deg);
}
.login-mascots[data-mood="success"] .login-mascot.is-red {
animation: login-mascot-hop-red 450ms ease-in-out;
}
.login-mascots[data-mood="success"] .login-mascot.is-green {
animation: login-mascot-hop-green 450ms ease-in-out 80ms;
}
.login-mascots[data-mood="fail"] .login-mascot-arm {
transform: translateY(6px);
}
@media (prefers-reduced-motion: reduce) {
.login-mascots[data-mood="idle"] .login-mascot-breathe,
.login-mascots.is-blinking .login-mascot-lid,
.login-mascots[data-mood="success"] .login-mascot.is-red,
.login-mascots[data-mood="success"] .login-mascot.is-green {
animation: none;
}
.login-mascot.is-green .login-mascot-figure,
.login-mascot-hand,
.login-mascot-arm {
transition: none;
}
}
.login-password-wrap {
position: relative;
display: block;
}
.login-portal .login-password-wrap input {
padding-right: 48px;
}
.login-password-toggle {
position: absolute;
top: 50%;
right: 8px;
z-index: 1;
min-height: 24px;
padding: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
font-size: var(--font-size-caption);
transform: translateY(-50%);
cursor: pointer;
}
.login-brand-copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: flex-end;
padding: 24px 0 20px;
padding: 0 0 20px;
}
.login-brand-kicker {
@@ -2119,6 +2454,50 @@ body.login-portal {
}
}
@keyframes login-mascot-breathe {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.015);
}
}
@keyframes login-mascot-blink {
0%,
100% {
transform: scaleY(0);
}
50% {
transform: scaleY(1);
}
}
@keyframes login-mascot-hop-red {
0%,
100% {
transform: translateY(0);
}
42% {
transform: translateY(-13px);
}
}
@keyframes login-mascot-hop-green {
0%,
100% {
transform: translateY(0);
}
42% {
transform: translateY(-11px);
}
}
.login-error {
margin: 0;
@@ -2466,7 +2845,8 @@ body.login-portal {
.login-brand-copy,
.login-brand-market,
.login-brand-disclaimer {
.login-brand-disclaimer,
.login-mascots {
display: none;
}
+7
View File
@@ -165,6 +165,13 @@
--login-candle-up: #e07078;
--login-candle-down: #3db88a;
--login-trend-line: rgba(244, 247, 255, 0.88);
--login-mascot-red: #e8605a;
--login-mascot-red-shade: #c74a45;
--login-mascot-green: #46be93;
--login-mascot-green-shade: #37997a;
--login-mascot-eye: #f7f9fc;
--login-mascot-pupil: #1a2440;
--login-mascot-height: clamp(150px, 15vw, 260px);
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
+110
View File
@@ -229,12 +229,26 @@ async function assertConfirmedSkeleton(page, { width, height }) {
expect(title.y).toBeLessThan(height * 0.72);
expect(stats.y).toBeGreaterThan(height * 0.55);
expect(chart.height).toBeGreaterThan(80);
await expect(page.locator("#loginMascots")).toBeVisible();
expect(card.width).toBeGreaterThan(380);
expect(card.width).toBeLessThan(450);
if (width === 1440) {
expect(brand.width).toBeGreaterThan(470);
expect(brand.width).toBeLessThan(520);
expect(brand.height).toBe(height);
const mascots = await page.locator("#loginMascots").boundingBox();
const kicker = await page.locator(".login-brand-kicker").boundingBox();
expect(mascots).toBeTruthy();
expect(kicker).toBeTruthy();
expect(mascots.y).toBeGreaterThan(header.y + header.height - 4);
expect(mascots.y + mascots.height).toBeLessThan(kicker.y + 8);
const gapTop = header.y + header.height;
const gapBottom = kicker.y;
const mid = (gapTop + gapBottom) / 2;
const mascotMid = mascots.y + mascots.height / 2;
expect(Math.abs(mascotMid - mid)).toBeLessThan(48);
expect(title.y).toBeGreaterThan(470);
expect(title.y).toBeLessThan(580);
} else if (width === 1920) {
expect(brand.width).toBeGreaterThan(540);
expect(brand.width).toBeLessThan(580);
@@ -444,3 +458,99 @@ test("workspace switch-account from a second page also returns to that page", as
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=ladderView/);
});
test("desktop mascots react to account focus, password, toggle, loading, success and failure", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await expect(mascots).toHaveAttribute("data-mood", "idle");
await page.locator("#loginUsername").focus();
await expect(mascots).toHaveAttribute("data-mood", "account");
await page.locator("#loginPassword").focus();
await expect(mascots).toHaveAttribute("data-mood", "password");
await expect.poll(async () => (
page.locator(".login-mascot.is-red .login-mascot-hand.is-left").evaluate((node) => getComputedStyle(node).opacity)
)).toBe("1");
await page.locator(".login-password-toggle").click();
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "text");
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.locator(".login-password-toggle").click();
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "password");
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-password-light-1440.png"), fullPage: true });
});
test("login loading and failure drive short mascot feedback", async ({ page }) => {
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts: [],
loginFails: true,
loginDelay: 800,
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("wrong-password");
const submit = page.locator(".login-submit").click();
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "busy");
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "fail");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-fail-dark-1440.png"), fullPage: true });
await submit;
});
test("login success plays a hop before leaving the portal", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
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("#loginMascots")).toHaveAttribute("data-mood", /busy|success/);
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "success", { timeout: 4000 });
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-success-light-1440.png"), fullPage: true });
await submit;
});
test("reduced motion keeps static mascots without mouse tracking", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await expect(mascots).toBeVisible();
await expect(mascots).toHaveAttribute("data-mood", "idle");
const before = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
await page.mouse.move(1200, 120);
await page.waitForTimeout(120);
const after = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
expect(after).toBe(before);
await page.locator("#loginPassword").focus();
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-reduced-dark-1440.png"), fullPage: true });
});
test("mouse follow updates mascot pupils on a fine pointer", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await page.mouse.move(80, 160);
await page.waitForTimeout(180);
const left = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
await page.mouse.move(1280, 200);
await page.waitForTimeout(180);
const right = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
expect(Number.parseFloat(right)).toBeGreaterThan(Number.parseFloat(left));
});
test("narrow screens hide mascots without moving the login card", async ({ page }) => {
await openPortal(page, { theme: "light", width: 800, height: 900, accounts: [] });
await expect(page.locator("#loginMascots")).toBeHidden();
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
await expect(page.locator(".login-brand-title")).toBeHidden();
});
test("theme toggle keeps mascots in the brand gap", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
await page.locator("#loginThemeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-idle-dark-1440.png"), fullPage: true });
});
+23
View File
@@ -44,6 +44,29 @@ class LoginPortalContractTests(unittest.TestCase):
self.assertIn("padding-bottom: 0;", AUTH)
self.assertNotIn("padding: 0 0 var(--statusbar-height);", AUTH)
def test_confirmed_mascots_fill_the_brand_gap(self) -> None:
for needle in (
'id="loginMascots"',
'aria-hidden="true"',
'class="login-mascot is-red"',
'class="login-mascot is-green"',
'class="login-mascot-back"',
'class="login-mascot-slit"',
):
self.assertIn(needle, LOGIN)
for needle in (
"--login-mascot-red: #e8605a;",
"--login-mascot-green: #46be93;",
"--login-mascot-height: clamp(150px, 15vw, 260px);",
):
self.assertIn(needle, TOKENS)
self.assertIn("flex: 1 1 auto;", AUTH)
self.assertIn(".login-mascots {", AUTH)
self.assertIn("prefers-reduced-motion: reduce", AUTH)
self.assertIn('next === "password"', LOGIN_JS)
self.assertIn("toggle-password", LOGIN_JS)
self.assertIn("celebrateLogin", LOGIN_JS)
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)