feat(HEL-251): 按确认稿实现登录页动态小K线人物
在桌面登录门户品牌空白带加入红绿小K线角色,支持焦点、密码遮挡、登录反馈和减少动态效果,且不移动原有文案与行情图。 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
865d8b0516
commit
4a63ccd10f
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user