From 67a7c4b87e86aabe33440a27edb5175597b82700 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Mon, 24 Aug 2026 07:32:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E4=B8=8D=E5=8F=AF=E7=82=B9=E3=80=81=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E9=94=AE=E7=83=AD=E5=8C=BA=E8=BF=87=E5=B0=8F=E3=80=81?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开关 - 我的 .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 --- client/src/App.tsx | 139 +++++++++++++++++++++++++++--- client/src/f7theme.css | 20 +++++ client/src/pages/Settings.css | 44 +++++++--- client/src/pages/SettingsPage.tsx | 14 ++- 4 files changed, 191 insertions(+), 26 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 3f86142..f2fd081 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,5 @@ 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'; @@ -95,12 +96,136 @@ function useNavProgress() { return busy; } +/** + * The progress bar lives on document.body, not inside . + * + * 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( +
+ +
, + 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(); + + // 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 ( - + + -
- -
- {TABS.map((tab) => ( @@ -151,7 +267,8 @@ function App() { /> ))} -
+
+ ); } diff --git a/client/src/f7theme.css b/client/src/f7theme.css index dca618f..d4c826c 100644 --- a/client/src/f7theme.css +++ b/client/src/f7theme.css @@ -158,3 +158,23 @@ border-radius: 0; 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; } +} diff --git a/client/src/pages/Settings.css b/client/src/pages/Settings.css index 4558ab1..f47442e 100644 --- a/client/src/pages/Settings.css +++ b/client/src/pages/Settings.css @@ -200,11 +200,22 @@ .set-input:focus { outline: none; color: var(--accent); } -/* Switch. The native checkbox stays in the DOM (and keeps keyboard and - screen-reader behaviour); only its painting is replaced. */ -.toggle { position: relative; flex-shrink: 0; width: 46px; height: 28px; } +/* Switch. + Named set-switch rather than toggle: Framework7 ships its own `.toggle` + 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; inset: 0; opacity: 0; @@ -213,18 +224,21 @@ height: 100%; cursor: pointer; z-index: 1; + -webkit-appearance: none; + appearance: none; } -.toggle-track { +.set-switch-track { position: absolute; inset: 0; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border-strong); transition: background 0.2s var(--ease), border-color 0.2s var(--ease); + pointer-events: none; } -.toggle-track::after { +.set-switch-track::after { content: ''; position: absolute; top: 2px; @@ -237,16 +251,22 @@ transition: transform 0.2s var(--ease); } -.toggle input:checked + .toggle-track { +.set-switch input:checked + .set-switch-track { background: var(--accent-solid); border-color: var(--accent-solid); } -.toggle input:checked + .toggle-track::after { transform: translateX(18px); } -.toggle input:focus-visible + .toggle-track { outline: 2px solid var(--accent); outline-offset: 2px; } +.set-switch input:checked + .set-switch-track::after { transform: translateX(18px); } + +.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; } -@media (prefers-reduced-motion: reduce) { - .toggle-track, .toggle-track::after { transition: none; } -} + diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 3bc20fb..b9274b4 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -48,15 +48,23 @@ function SettingsPage() { /* 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 - 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) => { setError(''); + const previous = settings; + setSettings((current) => (current ? { ...current, ...patch } : current)); try { const next = await apiClient.saveSettings(patch); setSettings(next); setSaved('已保存'); window.setTimeout(() => setSaved(''), 1600); } catch (err: any) { + setSettings(previous); setError(errorMessage(err, '保存失败')); } }; @@ -197,13 +205,13 @@ function SettingsPage() { 自动同步 后台按下面的频率拉取最新数据 - + save({ autoSync: e.target.checked })} /> -