fix(auth): 登录页不停刷新、无法输入
未登录时五个 Tab 视图会同时挂载各自的 Screen,每个都判定「没登录」,
于是每个都对**当前**视图发一次 navigate('/login/', {reloadAll: true})。
每次 reloadAll 又让其他几个重新挂载,再各发一次——登录表单被持续拆掉重建,
根本打不出字。关掉页面过渡动画后这个循环变紧,症状才明显起来。
判断登录与否是外壳的职责,不是每个页面各自抢着跳转:
- App 持有会话状态:没有会话就只渲染一个 /login/ 视图,连 Tab 栏都不出;
有会话才渲染五个 Tab。
- Screen 不再做任何跳转。
- setSession / clearSession 派发 ghl:auth 事件。storage 事件只在**其他**
标签页触发,同标签页的登录登出需要自己的信号。
- 登录成功后不再手动 navigate:外壳会换掉整个视图,从一个正要被卸载的
视图里发起路由是在和它抢。
实测:登出后登录页只有 1 个视图、无 Tab 栏,输入的内容 2.5 秒后仍在,
且输入框还是同一个 DOM 节点(没有重挂);恢复会话后自动切回 5 个 Tab。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import Framework7 from 'framework7/lite-bundle';
|
|||||||
import Framework7React from 'framework7-react';
|
import Framework7React from 'framework7-react';
|
||||||
|
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
import { apiClient, AUTH_EVENT } from './services/api';
|
||||||
|
|
||||||
import 'framework7/css/bundle';
|
import 'framework7/css/bundle';
|
||||||
// The icon font F7's iconIos/iconMd props reference; without it the props
|
// The icon font F7's iconIos/iconMd props reference; without it the props
|
||||||
@@ -217,8 +218,35 @@ function useRouterWatchdog() {
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there is a session, kept live for this tab.
|
||||||
|
*
|
||||||
|
* The gate belongs to the shell, not to each screen. It used to live in
|
||||||
|
* `Screen`, which every one of the five tab views renders at once — so a
|
||||||
|
* logged-out start had all five firing `navigate('/login/', {reloadAll:true})`
|
||||||
|
* at the same view, each reload remounting the others. The login form was
|
||||||
|
* being torn down and rebuilt continuously and could not be typed into.
|
||||||
|
*/
|
||||||
|
function useSession() {
|
||||||
|
const [authed, setAuthed] = useState(() => apiClient.isAuthenticated());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setAuthed(apiClient.isAuthenticated());
|
||||||
|
// `storage` covers other tabs; AUTH_EVENT covers this one.
|
||||||
|
window.addEventListener('storage', check);
|
||||||
|
window.addEventListener(AUTH_EVENT, check);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', check);
|
||||||
|
window.removeEventListener(AUTH_EVENT, check);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return authed;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useTheme();
|
useTheme();
|
||||||
|
const authed = useSession();
|
||||||
useClearBootFlag();
|
useClearBootFlag();
|
||||||
useRouterWatchdog();
|
useRouterWatchdog();
|
||||||
const navigating = useNavProgress();
|
const navigating = useNavProgress();
|
||||||
@@ -235,6 +263,10 @@ function App() {
|
|||||||
routes={routes}
|
routes={routes}
|
||||||
touch={{ tapHold: true }}
|
touch={{ tapHold: true }}
|
||||||
>
|
>
|
||||||
|
{!authed ? (
|
||||||
|
/* One view, no tab bar: there is nothing to navigate to yet. */
|
||||||
|
<View main url="/login/" className="safe-areas" animate={false} />
|
||||||
|
) : (
|
||||||
<Views tabs className="safe-areas">
|
<Views tabs className="safe-areas">
|
||||||
<Toolbar tabbar icons bottom>
|
<Toolbar tabbar icons bottom>
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
@@ -280,6 +312,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Views>
|
</Views>
|
||||||
|
)}
|
||||||
</F7App>
|
</F7App>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Page, Navbar, NavLeft, NavRight, Link, f7 } from 'framework7-react';
|
import { Page, Navbar, NavLeft, NavRight, Link, f7 } from 'framework7-react';
|
||||||
import { apiClient } from '../services/api';
|
|
||||||
import './Screen.css';
|
import './Screen.css';
|
||||||
|
|
||||||
interface ScreenProps {
|
interface ScreenProps {
|
||||||
@@ -10,8 +9,6 @@ interface ScreenProps {
|
|||||||
large?: boolean;
|
large?: boolean;
|
||||||
/** Back chevron instead of the app's utility links. */
|
/** Back chevron instead of the app's utility links. */
|
||||||
backLink?: boolean;
|
backLink?: boolean;
|
||||||
/** Skip the auth gate (the login screen itself). */
|
|
||||||
open?: boolean;
|
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -52,24 +49,8 @@ function goBack() {
|
|||||||
* collapsing large titles — while the content inside stays ours.
|
* collapsing large titles — while the content inside stays ours.
|
||||||
*/
|
*/
|
||||||
function Screen({
|
function Screen({
|
||||||
title, subtitle, large = true, backLink, open, right, children,
|
title, subtitle, large = true, backLink, right, children,
|
||||||
}: ScreenProps) {
|
}: ScreenProps) {
|
||||||
const [authed, setAuthed] = useState(() => apiClient.isAuthenticated());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open || authed) return;
|
|
||||||
// Router navigation rather than a redirect component: F7 owns history.
|
|
||||||
f7.views.current.router.navigate('/login/', { reloadAll: true });
|
|
||||||
}, [authed, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onStorage = () => setAuthed(apiClient.isAuthenticated());
|
|
||||||
window.addEventListener('storage', onStorage);
|
|
||||||
return () => window.removeEventListener('storage', onStorage);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!open && !authed) return <Page />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Navbar large={large} transparent={large} title={title} subtitle={subtitle}>
|
<Navbar large={large} transparent={large} title={title} subtitle={subtitle}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Page, f7 } from 'framework7-react';
|
import { Page } from 'framework7-react';
|
||||||
import { apiClient, errorMessage } from '../services/api';
|
import { apiClient, errorMessage } from '../services/api';
|
||||||
import './Login.css';
|
import './Login.css';
|
||||||
|
|
||||||
@@ -57,8 +57,10 @@ function Login() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { token } = await apiClient.login(loginEmail, loginPassword);
|
const { token } = await apiClient.login(loginEmail, loginPassword);
|
||||||
|
// No navigation here: setSession tells the shell there is a session,
|
||||||
|
// and it swaps the login view for the tab bar. Routing from a view that
|
||||||
|
// is about to be unmounted races that swap.
|
||||||
apiClient.setSession(token);
|
apiClient.setSession(token);
|
||||||
f7.views.main.router.navigate('/', { reloadAll: true });
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '登录失败'));
|
setError(errorMessage(err, '登录失败'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -94,8 +96,10 @@ function Login() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
||||||
|
// No navigation here: setSession tells the shell there is a session,
|
||||||
|
// and it swaps the login view for the tab bar. Routing from a view that
|
||||||
|
// is about to be unmounted races that swap.
|
||||||
apiClient.setSession(token);
|
apiClient.setSession(token);
|
||||||
f7.views.main.router.navigate('/', { reloadAll: true });
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '注册失败'));
|
setError(errorMessage(err, '注册失败'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import axios, { AxiosInstance } from 'axios';
|
|||||||
|
|
||||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||||
const TOKEN_KEY = 'ghl_token';
|
const TOKEN_KEY = 'ghl_token';
|
||||||
|
/** Fired on this tab whenever the session is created or cleared. */
|
||||||
|
export const AUTH_EVENT = 'ghl:auth';
|
||||||
|
|
||||||
// The Flask backend returns bare JSON (an array, or the object itself) and
|
// The Flask backend returns bare JSON (an array, or the object itself) and
|
||||||
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
|
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
|
||||||
@@ -385,12 +387,20 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- session ---
|
// --- session ---
|
||||||
|
/* `storage` only fires in *other* tabs, so a same-tab login or logout needs
|
||||||
|
its own signal for the shell to notice. */
|
||||||
|
private announce() {
|
||||||
|
window.dispatchEvent(new Event(AUTH_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
setSession(token: string) {
|
setSession(token: string) {
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
this.announce();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSession() {
|
clearSession() {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
this.announce();
|
||||||
}
|
}
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user