Files
GarminHealthLab/client/src/App.tsx
ericwyuan 67a7c4b87e fix(ui): 自动同步开关不可点、返回键热区过小、路由卡死
开关
- 我的 .toggle 类名和 Framework7 自带的 .toggle 组件撞了,元素被压成 0×0;
  另外 F7 全局给 input[type=checkbox] 加了 display:none,胜过我的 opacity:0,
  所以那个开关根本点不到。改名 set-switch 并显式恢复 input 的 display。
- 设置项改为乐观更新:先动控件,再发请求,失败回滚并报错。
  经隧道一个来回约半秒,开关在那半秒里纹丝不动会被当成坏了,
  用户再按一次,两个写入就打架了。

返回键
- 去掉导航栏毛玻璃背景时也把尺寸一起去掉了,链接缩成 44×16,
  手机上很难点中,按下也没有反馈。恢复 44px 高的热区并加按下态。

路由卡死
- F7 启动时给根节点加 framework7-initializing 来抑制动画,靠下一帧移除。
  这一帧在后台标签页或被节流的 webview 里不会到来,于是该类一直挂着,
  它的 transition-duration: 0ms !important 让过渡永远不发 transitionend,
  路由等的就是这个事件——allowPageChange 置 false 后再也没恢复,
  之后每一次导航(包括每一次返回)都被静默丢弃,只能刷新页面。
- 启动后主动清掉该类,并加一个看门狗:过渡类存活超过 1.6 秒即视为卡住,
  清理残留状态并放行路由。

进度条改用 portal 挂到 document.body。它原本是 F7 根节点的第一个子元素,
就这一个多余的节点会干扰 F7 的初始化。

部署脚本改为原子替换 static:原先 rm -rf 后再解包,中间有一段时间
文件是缺的,正好撞上就是 502。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 07:32:35 +08:00

276 lines
9.1 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 '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;
// Clear the leftovers F7 would have cleared itself.
el.classList.remove(
'router-transition', 'router-transition-forward',
'router-transition-backward', 'router-transition-css-forward',
'router-transition-css-backward'
);
el.querySelectorAll('.page').forEach((page) =>
page.classList.remove('page-next', 'page-previous'));
}
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); };
}, []);
}
function App() {
useTheme();
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 }}
>
<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=""
/>
))}
</Views>
</F7App>
</>
);
}
export default App;