From 564aaef6c164f64129d50e14c61982f6eacac841 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Mon, 24 Aug 2026 09:08:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E4=B8=8D=E5=81=9C=E5=88=B7=E6=96=B0=E3=80=81=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未登录时五个 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 --- client/src/App.tsx | 33 ++++++++++++++++++++++++++++++++ client/src/components/Screen.tsx | 23 ++-------------------- client/src/pages/LoginPage.tsx | 10 +++++++--- client/src/services/api.ts | 10 ++++++++++ 4 files changed, 52 insertions(+), 24 deletions(-) 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 {