feat(HEL-76): 手机端丝滑度整改②——换页过渡与启动登录反馈

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-08-24 11:11:17 +08:00
co-authored by multica-agent
parent 7d2161decd
commit bb9d92c643
4 changed files with 111 additions and 15 deletions
+62 -1
View File
@@ -27,6 +27,23 @@ body {
border-color var(--motion-pop) var(--ease-press); border-color var(--motion-pop) var(--ease-press);
} }
#m-boot-splash {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas);
z-index: 200;
}
#m-boot-splash .m-brand-mark {
margin-bottom: 0;
}
.m-motion-fade-in { .m-motion-fade-in {
animation: m-fade-in var(--motion-fade) var(--ease-press) both; animation: m-fade-in var(--motion-fade) var(--ease-press) both;
} }
@@ -45,6 +62,28 @@ body {
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
.m-motion-push-in {
animation: m-push-in var(--motion-enter) var(--ease-enter) both;
}
@keyframes m-push-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.m-motion-pop-in {
animation: m-pop-in var(--motion-pop) var(--ease-enter) both;
}
@keyframes m-pop-in {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: none; }
}
.m-motion-boot-in {
animation: m-fade-in var(--motion-press-release) var(--ease-press) both;
}
.m-header { .m-header {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -301,6 +340,10 @@ body {
.m-btn-primary { .m-btn-primary {
width: 100%; width: 100%;
height: 44px; height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
background: var(--action); background: var(--action);
@@ -321,6 +364,22 @@ body {
.m-btn-primary:disabled { .m-btn-primary:disabled {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
pointer-events: none;
}
.m-btn-spinner {
display: inline-block;
width: 14px;
height: 14px;
flex: 0 0 auto;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: m-spin 800ms linear infinite;
}
@keyframes m-spin {
to { transform: rotate(360deg); }
} }
.m-tabbar { .m-tabbar {
@@ -476,7 +535,9 @@ body {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.m-motion-rise-in { .m-motion-rise-in,
.m-motion-push-in,
.m-motion-pop-in {
animation-name: m-fade-in; animation-name: m-fade-in;
animation-duration: 100ms; animation-duration: 100ms;
} }
+3
View File
@@ -21,6 +21,9 @@
document.getElementById("m-app").setAttribute("data-theme", theme); document.getElementById("m-app").setAttribute("data-theme", theme);
})(); })();
</script> </script>
<div id="m-boot-splash" aria-hidden="true">
<span class="m-brand-mark"></span>
</div>
<header id="m-header" class="m-header"> <header id="m-header" class="m-header">
<button id="m-back" class="m-header-btn" type="button" aria-label="返回" hidden> <button id="m-back" class="m-header-btn" type="button" aria-label="返回" hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
+6
View File
@@ -67,9 +67,15 @@
} else if (/^#\/?auth/.test(hash)) { } else if (/^#\/?auth/.test(hash)) {
global.location.replace("#/hub/market"); global.location.replace("#/hub/market");
} }
removeBootSplash();
global.MobileRouter.init(); global.MobileRouter.init();
} }
function removeBootSplash() {
const splash = document.getElementById("m-boot-splash");
if (splash) splash.remove();
}
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true }); document.addEventListener("DOMContentLoaded", boot, { once: true });
} else { } else {
+40 -14
View File
@@ -56,6 +56,17 @@
let internalNav = 0; let internalNav = 0;
let authMode = "login"; let authMode = "login";
const VIEW_MOTION_CLASSES = ["m-motion-fade-in", "m-motion-push-in", "m-motion-pop-in", "m-motion-boot-in"];
function applyViewMotion(motion) {
const view = document.getElementById("m-view");
VIEW_MOTION_CLASSES.forEach(function (cls) {
view.classList.remove(cls);
});
void view.offsetWidth;
view.classList.add(motion || "m-motion-fade-in");
}
function currentHash() { function currentHash() {
let hash = window.location.hash || ""; let hash = window.location.hash || "";
if (!hash || hash === "#" || hash === "#/") return DEFAULT_HASH; if (!hash || hash === "#" || hash === "#/") return DEFAULT_HASH;
@@ -92,14 +103,14 @@
function navigate(hash) { function navigate(hash) {
stack.push(hash); stack.push(hash);
setHash(hash); setHash(hash);
render(); render("m-motion-push-in");
} }
function replace(hash) { function replace(hash, motion) {
if (stack.length) stack[stack.length - 1] = hash; if (stack.length) stack[stack.length - 1] = hash;
else stack.push(hash); else stack.push(hash);
setHash(hash); setHash(hash);
render(); render(motion);
} }
function resetStack(hash) { function resetStack(hash) {
@@ -118,11 +129,11 @@
if (stack.length > 1) { if (stack.length > 1) {
stack.pop(); stack.pop();
setHash(stack[stack.length - 1]); setHash(stack[stack.length - 1]);
render(); render("m-motion-pop-in");
return; return;
} }
const parent = parentRoute(currentRoute()); const parent = parentRoute(currentRoute());
if (parent) replace(parent); if (parent) replace(parent, "m-motion-pop-in");
} }
function switchEntry(key) { function switchEntry(key) {
@@ -240,7 +251,7 @@
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>', '<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>', '<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
'<p class="m-auth-error" id="m-auth-error" hidden></p>', '<p class="m-auth-error" id="m-auth-error" hidden></p>',
'<button class="m-btn-primary" id="m-auth-submit" type="submit">登录</button>', '<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
"</form>", "</form>",
"</div>" "</div>"
].join(""); ].join("");
@@ -256,7 +267,8 @@
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register"; document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
document.getElementById("m-auth-confirm").required = authMode === "register"; document.getElementById("m-auth-confirm").required = authMode === "register";
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password"; document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
document.getElementById("m-auth-submit").textContent = authMode === "register" ? "注册并进入" : "登录"; const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
document.getElementById("m-auth-error").hidden = true; document.getElementById("m-auth-error").hidden = true;
} }
@@ -267,18 +279,29 @@
document.getElementById("m-auth-form").addEventListener("submit", submitAuth); document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
} }
function showAuthError(element, message) {
element.textContent = message;
element.classList.remove("m-motion-fade-in");
void element.offsetWidth;
element.classList.add("m-motion-fade-in");
element.hidden = false;
}
async function submitAuth(event) { async function submitAuth(event) {
event.preventDefault(); event.preventDefault();
const username = document.getElementById("m-auth-username").value.trim(); const username = document.getElementById("m-auth-username").value.trim();
const password = document.getElementById("m-auth-password").value; const password = document.getElementById("m-auth-password").value;
const errorElement = document.getElementById("m-auth-error"); const errorElement = document.getElementById("m-auth-error");
if (authMode === "register" && password !== document.getElementById("m-auth-confirm").value) { if (authMode === "register" && password !== document.getElementById("m-auth-confirm").value) {
errorElement.textContent = "两次输入的密码不一致。"; showAuthError(errorElement, "两次输入的密码不一致。");
errorElement.hidden = false;
return; return;
} }
const submit = document.getElementById("m-auth-submit"); const submit = document.getElementById("m-auth-submit");
const spinner = submit.querySelector(".m-btn-spinner");
const label = submit.querySelector(".m-btn-label");
submit.disabled = true; submit.disabled = true;
spinner.hidden = false;
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
try { try {
if (authMode === "register") { if (authMode === "register") {
await global.MobileSession.register(username, password); await global.MobileSession.register(username, password);
@@ -287,10 +310,11 @@
} }
replace(DEFAULT_HASH); replace(DEFAULT_HASH);
} catch (error) { } catch (error) {
errorElement.textContent = error.message || "账号操作失败"; showAuthError(errorElement, error.message || "账号操作失败");
errorElement.hidden = false;
} finally { } finally {
submit.disabled = false; submit.disabled = false;
spinner.hidden = true;
label.textContent = authMode === "register" ? "注册并进入" : "登录";
} }
} }
@@ -312,7 +336,7 @@
}); });
} }
function render() { function render(motion) {
const route = currentRoute(); const route = currentRoute();
if (route.name === "home") { if (route.name === "home") {
replace(DEFAULT_HASH); replace(DEFAULT_HASH);
@@ -331,6 +355,7 @@
else if (route.name === "feature") renderFeature(route.params.join("/")); else if (route.name === "feature") renderFeature(route.params.join("/"));
else if (route.name === "assistant") renderAssistant(); else if (route.name === "assistant") renderAssistant();
else if (route.name === "auth") renderAuth(); else if (route.name === "auth") renderAuth();
applyViewMotion(motion);
} }
function bindGlobalEvents() { function bindGlobalEvents() {
@@ -359,10 +384,11 @@
const hash = currentHash(); const hash = currentHash();
if (stack.length > 1 && hash === stack[stack.length - 2]) { if (stack.length > 1 && hash === stack[stack.length - 2]) {
stack.pop(); stack.pop();
render("m-motion-pop-in");
} else if (hash !== stack[stack.length - 1]) { } else if (hash !== stack[stack.length - 1]) {
stack.push(hash); stack.push(hash);
render("m-motion-push-in");
} }
render();
}); });
} }
@@ -377,7 +403,7 @@
} else { } else {
stack.push(currentHash()); stack.push(currentHash());
} }
render(); render("m-motion-boot-in");
} }
global.MobileRouter = { global.MobileRouter = {