打开 …/sleep/ 之后,底部点「今日」出来的是睡眠,点「健康」出来的健康从来
没去过地址栏说的地方。
原因:browserHistory 只开在主视图(今日)上,而 Framework7 在启动时也会用
它去消费地址栏——它只认识这一个视图。于是不管哪个标签页真正拥有那个路径,
睡眠都被压进了**今日**的栈,还顺手伪造了一份两条记录的 history。
- View 加 browserHistoryOnLoad={false}。注意不是 browserHistoryInitialMatch
——那个只决定两条记录里渲染哪一条,伪造的栈照样留着(读 router-class.js
的 getInitialUrl 才看明白)。之后的导航追踪不受影响。
- routes.ts 加 tabForPath():冷启动的 URL 归哪个标签页。只有深链接查这张表,
应用内点击照旧——从今日卡片点进睡眠,仍然留在今日。
- App.tsx 的 useDeepLink 负责派发初始地址,并把目标页压在该标签页自己的根
之上,这样返回箭头回到的是健康,而不是空栈。
派发要延后一帧再做、并在 unmount 时取消:StrictMode 会把 <View> 挂两次,
第一次挂载的 F7 视图会被销毁重建,在它上面导航的结果全丢——症状是一个睡眠
页孤零零留在 DOM 里,router 却坚称自己在健康。另外比较路径要先归一化斜杠,
每个页面都注册了带斜杠和不带斜杠两种写法,直接比会把趋势压在趋势上面。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
15 KiB
TypeScript
399 lines
15 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, { tabForPath } from './routes';
|
|
import Copilot from './components/Copilot';
|
|
import { FEATURES } from './features';
|
|
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); };
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* Open a cold URL in the tab that owns it.
|
|
*
|
|
* The main view has `browserHistory`, which is what gives 今日 shareable
|
|
* URLs — but Framework7 also applies it on boot, and it only knows about
|
|
* that one view. So loading `…/sleep/` pushed 睡眠 onto **今日's** stack:
|
|
* the tab bar highlighted 今日, tapping 今日 showed 睡眠, and tapping 健康
|
|
* showed a 健康 that had never been where the address bar said it was.
|
|
*
|
|
* `browserHistoryInitialMatch={false}` on that view stops F7 from doing it,
|
|
* and the initial address is dispatched here instead — to the tab that owns
|
|
* the path, with that tab activated. In-app navigation is untouched: a detail
|
|
* screen still opens inside whichever tab you were already in.
|
|
*/
|
|
/* Module scope, not a ref: this must happen once per page load, and a ref is
|
|
reset by the remount it needs to survive. */
|
|
let deepLinkDispatched = false;
|
|
|
|
function useDeepLink(authed: boolean) {
|
|
useEffect(() => {
|
|
if (!authed || deepLinkDispatched) return;
|
|
|
|
const path = window.location.pathname;
|
|
const tab = tabForPath(path);
|
|
if (!tab) return;
|
|
|
|
/* Deferred, with the timer cleared on unmount, because StrictMode mounts
|
|
<View> twice: the first mount's Framework7 view is destroyed and rebuilt
|
|
from its `url` prop, so anything navigated on it is discarded — leaving
|
|
a 睡眠 page orphaned in the DOM while the router insisted it was on
|
|
健康. Cancelling on unmount means only the surviving mount dispatches. */
|
|
const timer = window.setTimeout(() => {
|
|
f7ready((app) => {
|
|
const view = app.views.get(`#view-${tab}`);
|
|
if (!view) return;
|
|
deepLinkDispatched = true;
|
|
app.tab.show(`#view-${tab}`);
|
|
// The tab's own root is already loaded, so this pushes the deep-linked
|
|
// screen on top of it — which is what makes the back chevron return to
|
|
// 健康 rather than to an empty stack. Skipped when the path *is* that
|
|
// root, or the tab would stack a duplicate of what it already shows.
|
|
//
|
|
// Compared with trailing slashes normalised, because every screen is
|
|
// registered under both spellings (see routes.ts): `…/trends` and the
|
|
// view's own `/trends/` are the same screen, and comparing them raw
|
|
// stacked 趋势 on top of 趋势.
|
|
const slashed = (url: string) => (url.endsWith('/') ? url : `${url}/`);
|
|
const current = view.router.history[view.router.history.length - 1] || '';
|
|
if (slashed(current) !== slashed(path)) {
|
|
view.router.navigate(slashed(path), { animate: false });
|
|
}
|
|
});
|
|
}, 0);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
// Runs once per page load: re-running it on a later render would yank the
|
|
// user back to the address they arrived at.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [authed]);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
useDeepLink(authed);
|
|
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'}
|
|
/* Stops F7 from consuming the address bar on boot: it would load
|
|
that path into THIS view whichever tab actually owns it, and
|
|
fabricate a two-entry history to match. `…InitialMatch={false}`
|
|
is not enough — it only changes which of the two entries gets
|
|
rendered, leaving the bogus stack in place. Tracking for later
|
|
navigation is unaffected; the initial address is dispatched by
|
|
useDeepLink instead. */
|
|
browserHistoryOnLoad={false}
|
|
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>
|
|
|
|
{/* Outside <F7App> for the same reason NavProgress is: a stray child of
|
|
the Framework7 root breaks its initialisation. Only rendered with a
|
|
session — there is nothing to ask about on the login screen. */}
|
|
{authed && FEATURES.ai && <Copilot />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default App;
|