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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-react';
|
import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-react';
|
||||||
import Framework7 from 'framework7/lite-bundle';
|
import Framework7 from 'framework7/lite-bundle';
|
||||||
import Framework7React from 'framework7-react';
|
import Framework7React from 'framework7-react';
|
||||||
@@ -95,12 +96,136 @@ function useNavProgress() {
|
|||||||
return busy;
|
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() {
|
function App() {
|
||||||
useTheme();
|
useTheme();
|
||||||
|
useClearBootFlag();
|
||||||
|
useRouterWatchdog();
|
||||||
const navigating = useNavProgress();
|
const navigating = useNavProgress();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<F7App
|
<>
|
||||||
|
<NavProgress on={navigating} />
|
||||||
|
<F7App
|
||||||
name="Garmin Health Lab"
|
name="Garmin Health Lab"
|
||||||
// iOS only: the Material variants would read as a different app on the
|
// 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.
|
// same screen, and the reference this is modelled on is an iOS app.
|
||||||
@@ -109,15 +234,6 @@ function App() {
|
|||||||
routes={routes}
|
routes={routes}
|
||||||
touch={{ tapHold: true }}
|
touch={{ tapHold: true }}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className={`nav-progress ${navigating ? 'on' : ''}`}
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label={navigating ? '正在打开' : ''}
|
|
||||||
>
|
|
||||||
<span className="nav-progress-bar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Views tabs className="safe-areas">
|
<Views tabs className="safe-areas">
|
||||||
<Toolbar tabbar icons bottom>
|
<Toolbar tabbar icons bottom>
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
@@ -151,7 +267,8 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Views>
|
</Views>
|
||||||
</F7App>
|
</F7App>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,3 +158,23 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The back chevron's hit area.
|
||||||
|
Stripping the navbar pane's frosted background also took its size with it:
|
||||||
|
the anchor shrink-wrapped to 44x16, which is a hard target on a phone and
|
||||||
|
gave no press feedback. 44px tall is the platform minimum. */
|
||||||
|
.ios .navbar .left a.link,
|
||||||
|
.ios .navbar .right a.link {
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios .navbar .left a.link:active,
|
||||||
|
.ios .navbar .right a.link:active { opacity: 0.4; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ios .navbar .left a.link,
|
||||||
|
.ios .navbar .right a.link { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,11 +200,22 @@
|
|||||||
|
|
||||||
.set-input:focus { outline: none; color: var(--accent); }
|
.set-input:focus { outline: none; color: var(--accent); }
|
||||||
|
|
||||||
/* Switch. The native checkbox stays in the DOM (and keeps keyboard and
|
/* Switch.
|
||||||
screen-reader behaviour); only its painting is replaced. */
|
Named set-switch rather than toggle: Framework7 ships its own `.toggle`
|
||||||
.toggle { position: relative; flex-shrink: 0; width: 46px; height: 28px; }
|
component, and the collision sized this one to 0x0 — an invisible control
|
||||||
|
that also could not be clicked. F7 additionally hides every native
|
||||||
|
checkbox with `display: none`, which beats opacity, so the input's display
|
||||||
|
is restored explicitly here. */
|
||||||
|
.set-switch {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 46px;
|
||||||
|
height: 28px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle input {
|
.set-switch input[type="checkbox"] {
|
||||||
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -213,18 +224,21 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-track {
|
.set-switch-track {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
transition: background 0.2s var(--ease), border-color 0.2s var(--ease);
|
transition: background 0.2s var(--ease), border-color 0.2s var(--ease);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-track::after {
|
.set-switch-track::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -237,16 +251,22 @@
|
|||||||
transition: transform 0.2s var(--ease);
|
transition: transform 0.2s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle input:checked + .toggle-track {
|
.set-switch input:checked + .set-switch-track {
|
||||||
background: var(--accent-solid);
|
background: var(--accent-solid);
|
||||||
border-color: var(--accent-solid);
|
border-color: var(--accent-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
|
.set-switch input:checked + .set-switch-track::after { transform: translateX(18px); }
|
||||||
.toggle input:focus-visible + .toggle-track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
|
.set-switch input:focus-visible + .set-switch-track {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.set-switch-track, .set-switch-track::after { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.rb-bands { font-variant-numeric: tabular-nums; }
|
.rb-bands { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.toggle-track, .toggle-track::after { transition: none; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,15 +48,23 @@ function SettingsPage() {
|
|||||||
|
|
||||||
/* Saved on change rather than behind a 保存 button: every field here is a
|
/* Saved on change rather than behind a 保存 button: every field here is a
|
||||||
single value with an obvious effect, and a form that can be left dirty
|
single value with an obvious effect, and a form that can be left dirty
|
||||||
is a form that silently loses edits. */
|
is a form that silently loses edits.
|
||||||
|
|
||||||
|
The control moves first and the request follows. A round trip through the
|
||||||
|
tunnel is roughly half a second, and a switch that sits still that long
|
||||||
|
reads as broken — the user presses it again, and now two writes race.
|
||||||
|
On failure the value snaps back and the error says why. */
|
||||||
const save = async (patch: Partial<UserSettings>) => {
|
const save = async (patch: Partial<UserSettings>) => {
|
||||||
setError('');
|
setError('');
|
||||||
|
const previous = settings;
|
||||||
|
setSettings((current) => (current ? { ...current, ...patch } : current));
|
||||||
try {
|
try {
|
||||||
const next = await apiClient.saveSettings(patch);
|
const next = await apiClient.saveSettings(patch);
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
setSaved('已保存');
|
setSaved('已保存');
|
||||||
window.setTimeout(() => setSaved(''), 1600);
|
window.setTimeout(() => setSaved(''), 1600);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
setSettings(previous);
|
||||||
setError(errorMessage(err, '保存失败'));
|
setError(errorMessage(err, '保存失败'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -197,13 +205,13 @@ function SettingsPage() {
|
|||||||
自动同步
|
自动同步
|
||||||
<span className="set-sub">后台按下面的频率拉取最新数据</span>
|
<span className="set-sub">后台按下面的频率拉取最新数据</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="toggle">
|
<span className="set-switch">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={s.autoSync}
|
checked={s.autoSync}
|
||||||
onChange={(e) => save({ autoSync: e.target.checked })}
|
onChange={(e) => save({ autoSync: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<span className="toggle-track" aria-hidden="true" />
|
<span className="set-switch-track" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user