diff --git a/client/src/App.tsx b/client/src/App.tsx index 6a4d7ef..2410a9f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import Framework7 from 'framework7/lite-bundle'; import Framework7React from 'framework7-react'; import routes from './routes'; +import { apiClient, AUTH_EVENT } from './services/api'; import 'framework7/css/bundle'; // 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() { useTheme(); + const authed = useSession(); useClearBootFlag(); useRouterWatchdog(); const navigating = useNavProgress(); @@ -235,6 +263,10 @@ function App() { routes={routes} touch={{ tapHold: true }} > + {!authed ? ( + /* One view, no tab bar: there is nothing to navigate to yet. */ + + ) : ( {TABS.map((tab) => ( @@ -280,6 +312,7 @@ function App() { /> ))} + )} ); diff --git a/client/src/components/Screen.tsx b/client/src/components/Screen.tsx index 2e19a59..21cebc1 100644 --- a/client/src/components/Screen.tsx +++ b/client/src/components/Screen.tsx @@ -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 { apiClient } from '../services/api'; import './Screen.css'; interface ScreenProps { @@ -10,8 +9,6 @@ interface ScreenProps { large?: boolean; /** Back chevron instead of the app's utility links. */ backLink?: boolean; - /** Skip the auth gate (the login screen itself). */ - open?: boolean; right?: ReactNode; children: ReactNode; } @@ -52,24 +49,8 @@ function goBack() { * collapsing large titles — while the content inside stays ours. */ function Screen({ - title, subtitle, large = true, backLink, open, right, children, + title, subtitle, large = true, backLink, right, children, }: 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 ; - return ( diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index c566b06..f93be24 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Page, f7 } from 'framework7-react'; +import { Page } from 'framework7-react'; import { apiClient, errorMessage } from '../services/api'; import './Login.css'; @@ -57,8 +57,10 @@ function Login() { try { 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); - f7.views.main.router.navigate('/', { reloadAll: true }); } catch (err: any) { setError(errorMessage(err, '登录失败')); } finally { @@ -94,8 +96,10 @@ function Login() { try { 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); - f7.views.main.router.navigate('/', { reloadAll: true }); } catch (err: any) { setError(errorMessage(err, '注册失败')); } finally { diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 293e554..c4837d9 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -2,6 +2,8 @@ import axios, { AxiosInstance } from 'axios'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; 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 // signals failure with `{ error: "..." }` plus a non-2xx status. There is no @@ -385,12 +387,20 @@ class ApiClient { } // --- 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) { localStorage.setItem(TOKEN_KEY, token); + this.announce(); } clearSession() { localStorage.removeItem(TOKEN_KEY); + this.announce(); } isAuthenticated(): boolean {