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 {