Files
GarminHealthLab/client/src/App.tsx
ericwyuan 564aaef6c1 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>
2026-08-24 09:08:02 +08:00

322 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-react';
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
// render as their raw names.
import 'framework7-icons';
import './f7theme.css';
Framework7.use(Framework7React);
/** The five thumb-reachable destinations. Secondary screens are pushed on top
* of whichever tab is active, the way an iOS app stacks them. */
const TABS = [
{ id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' },
{ id: 'health', path: '/health/', label: '健康', icon: 'heart' },
{ id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' },
{ id: 'exercise', path: '/exercise/', label: '运动', icon: 'flame' },
{ id: 'settings', path: '/settings/', label: '设置', icon: 'gear_alt' },
];
/**
* Dark mode is a deliberate, validated palette rather than an inversion. The
* choice is stamped on <html> for our own tokens, and mirrored onto F7's
* `.dark` class so its chrome follows the same switch.
*/
function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>(
() => (localStorage.getItem('ghl_theme') as any) || 'system'
);
useEffect(() => {
const root = document.documentElement;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
root.classList.toggle(
'dark',
theme === 'dark' || (theme === 'system' && media.matches)
);
};
apply();
localStorage.setItem('ghl_theme', theme);
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, [theme]);
return [theme, setTheme] as const;
}
/**
* A progress line across the top while a screen is being pushed.
*
* The transition itself is instant; what the user waits on is the new page's
* first fetch, which without this reads as a dead tap. Shown on route change
* and cleared once the incoming page has settled, with a floor on how briefly
* it can appear so a fast navigation does not produce a flash.
*/
function useNavProgress() {
const [busy, setBusy] = useState(false);
useEffect(() => {
let shownAt = 0;
let timer: number | undefined;
const show = () => {
shownAt = Date.now();
window.clearTimeout(timer);
setBusy(true);
};
const hide = () => {
const elapsed = Date.now() - shownAt;
const wait = Math.max(0, 260 - elapsed);
window.clearTimeout(timer);
timer = window.setTimeout(() => setBusy(false), wait);
};
f7ready((app) => {
app.on('routeChange', show);
app.on('pageAfterIn', hide);
app.on('pageBeforeRemove', hide);
});
return () => window.clearTimeout(timer);
}, []);
return busy;
}
/**
* The progress bar lives on document.body, not inside <F7App>.
*
* It was originally a child of the Framework7 root, and that single stray
* element broke F7's initialisation: `framework7-initializing` was never
* cleared, which forces `transition-duration: 0ms` on everything, so
* `transitionend` never fired, so the router never finished a transition —
* leaving every page with `pointer-events: none`. The visible symptom was
* back buttons that did nothing, app-wide.
*/
function NavProgress({ on }: { on: boolean }) {
return createPortal(
<div
className={`nav-progress ${on ? 'on' : ''}`}
role="status"
aria-live="polite"
aria-label={on ? '正在打开' : ''}
>
<span className="nav-progress-bar" />
</div>,
document.body
);
}
/**
* Clear Framework7's boot flag once the app is up.
*
* F7 puts `framework7-initializing` on its root to suppress animation during
* boot, and removes it on the next animation frame. When that frame does not
* arrive — a backgrounded tab, a throttled webview — the class stays, and its
* `transition-duration: 0ms !important` means transitions never emit
* `transitionend`. The router waits for that event to finish a page change, so
* it sets `allowPageChange = false` and never sets it back: every subsequent
* navigation, including every back button, silently does nothing.
*
* Clearing it ourselves is safe — by the time this runs the app is mounted,
* which is all the flag was suppressing.
*/
function useClearBootFlag() {
useEffect(() => {
const clear = () => document
.querySelector('.framework7-root')
?.classList.remove('framework7-initializing');
f7ready(clear);
// Belt and braces: f7ready itself can be scheduled off a frame.
const timer = window.setTimeout(clear, 600);
return () => window.clearTimeout(timer);
}, []);
}
/**
* Keep the router from staying blocked.
*
* Framework7 sets `router.allowPageChange = false` when a page transition
* starts and restores it when the transition's `animationend` arrives. If that
* event never comes — a throttled or backgrounded webview, where the browser
* does not run CSS animations at all — the flag stays false and every later
* navigation is silently dropped. The visible symptom is a back button that
* does nothing, and the only recovery is a full reload.
*
* This releases the flag once a page has settled, which is strictly later than
* the transition F7 is waiting on.
*/
function useRouterWatchdog() {
useEffect(() => {
let timer: number | undefined;
/* Walk the DOM rather than an app-level view registry: the shape of that
registry differs between Framework7 versions, the view elements do not. */
const stuckSince = new WeakMap<Element, number>();
// A page transition is 400ms. Anything still "transitioning" well past
// that is not transitioning — it is waiting for an event that will never
// arrive, and the stale classes are themselves what block recovery.
const STUCK_AFTER_MS = 1600;
const release = () => {
document.querySelectorAll('.view').forEach((el) => {
const view = (el as any).f7View;
if (!view?.router) return;
const midTransition = el.classList.contains('router-transition');
if (midTransition) {
const since = stuckSince.get(el);
if (!since) { stuckSince.set(el, Date.now()); return; }
if (Date.now() - since < STUCK_AFTER_MS) return;
// Only the view's transition flags. A page's own page-previous /
// page-next class is how F7 identifies the page to go back to;
// removing those makes back() rebuild the screen and stack a
// duplicate rather than popping.
el.classList.remove(
'router-transition', 'router-transition-forward',
'router-transition-backward', 'router-transition-css-forward',
'router-transition-css-backward'
);
}
stuckSince.delete(el);
if (view.router.allowPageChange === false) {
view.router.allowPageChange = true;
}
});
};
f7ready((app) => {
app.on('pageAfterIn', () => {
window.clearTimeout(timer);
timer = window.setTimeout(release, 600);
});
});
// pageAfterIn is itself part of the transition pipeline, so it can be
// missed too. A slow poll costs nothing and guarantees recovery.
const poll = window.setInterval(release, 1500);
return () => { window.clearTimeout(timer); window.clearInterval(poll); };
}, []);
}
/**
* 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();
return (
<>
<NavProgress on={navigating} />
<F7App
name="Garmin Health Lab"
// iOS only: the Material variants would read as a different app on the
// same screen, and the reference this is modelled on is an iOS app.
theme="ios"
darkMode="auto"
routes={routes}
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">
<Toolbar tabbar icons bottom>
{TABS.map((tab) => (
<Link
key={tab.id}
tabLink={`#view-${tab.id}`}
tabLinkActive={tab.id === 'today'}
iconIos={`f7:${tab.icon}`}
iconMd={`f7:${tab.icon}`}
text={tab.label}
/>
))}
</Toolbar>
{/* Each tab keeps its own navigation stack, so pushing a detail screen
inside 健康 does not disturb where 趋势 was left. */}
{TABS.map((tab) => (
<View
key={tab.id}
id={`view-${tab.id}`}
name={tab.id}
main={tab.id === 'today'}
tab
tabActive={tab.id === 'today'}
url={tab.path}
// Only the main view drives the address bar. With it enabled on
// every tab each one reads the browser URL instead of its own
// `url`, so all five loaded the root page.
browserHistory={tab.id === 'today'}
browserHistorySeparator=""
/* Framework7's page transition is animation-driven: the router
sets allowPageChange = false on push and restores it when the
animation reports it ended. When that report never arrives the
router stays blocked and every later navigation — including
every back button — is silently dropped, and back() rebuilds
the previous screen instead of popping it.
Turning the transition off makes each page change complete
synchronously, which is correct everywhere. The motion comes
from the content instead: cards and heroes animate in, and the
route progress bar covers the load. */
animate={false}
/>
))}
</Views>
)}
</F7App>
</>
);
}
export default App;