From 8a9bc5843375c52b2fe1f10a81ab77b24e51d21a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 11:13:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E6=A8=AA=E7=AB=96=E5=B1=8F?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94=EF=BC=88=E7=B4=A7=E5=87=91=E5=8F=8C?= =?UTF-8?q?=E6=A0=8F=EF=BC=89+=20=E6=97=8B=E5=B1=8F=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「为什么窄屏的横屏和竖屏没有自适应」。 ## 根因 断点是**纯宽度**的:`(max-width: 1023px)`。手机竖屏 390 与横屏 844 **都落在同一档** ⇒ 布局一个像素都不变,横屏多出来的 ~450px 全浪费在空白上。看起来就是"没自适应"。 ## 改动 - 新增 `useCompactTwoPane()`:`(min-width: 700px) and (max-height: 560px)` —— 仍是紧凑外壳(悬浮底部导航),但内容区从**覆盖式**改成**列表 + 详情并排**。 用高度而不是 `landscape` 判:桌面窗口压扁、分屏、软键盘弹起都会命中, 而它们要的是同一件事(别浪费横向空间)。 - App:`compactTwoPane && hasList` 时走并排分支,其余情况仍是 NarrowStack 覆盖式。 - `--app-height` 增加 `orientationchange` 监听并延后一帧重算(iOS 上部分版本的 resize 不跟着方向变化走;立刻读会拿到旧高度)。 ## 判据 `test/manual/rotate-verify.mjs` 8 条,含**可逆性**与前置对照: 竖屏覆盖式 → 横屏并排(真的换了 class)→ 旋回竖屏恢复覆盖式; 三态的 `--app-height` 分别为 844 / 390 / 844(不是旧值);横屏无横向溢出、 底部导航仍完整可见且浮着(left=20)。实测 **8/8**。 同一轮还补上了 `test/manual/calendar-swipe-verify.mjs`(日历滑动,4 条)。 --- client/electron/src/App.tsx | 13 +++- client/electron/src/hooks/useIsNarrow.ts | 29 +++++++++ client/electron/src/main.tsx | 6 ++ client/electron/test/manual/rotate-verify.mjs | 63 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 client/electron/test/manual/rotate-verify.mjs diff --git a/client/electron/src/App.tsx b/client/electron/src/App.tsx index d8722f2..23df23f 100644 --- a/client/electron/src/App.tsx +++ b/client/electron/src/App.tsx @@ -28,7 +28,7 @@ import AdminUsersPage from './components/AdminUsersPage'; const CalendarView = lazy(() => import('./components/CalendarView')); import NarrowNav from './components/NarrowNav'; import NarrowStack from './components/NarrowStack'; -import { useIsNarrow } from './hooks/useIsNarrow'; +import { useCompactTwoPane, useIsNarrow } from './hooks/useIsNarrow'; export default function App() { const phase = useAuthStore(s => s.phase); @@ -40,6 +40,8 @@ export default function App() { const resetUI = useUIStore(s => s.reset); const narrowPane = useUIStore(s => s.narrowPane); const narrow = useIsNarrow(); + // 宽而矮(手机横屏)时紧凑外壳内容区改并排,见 useCompactTwoPane 的说明 + const compactTwoPane = useCompactTwoPane(); const commTab = useUIStore(s => s.commTab); /* @@ -219,6 +221,15 @@ export default function App() { ); } + // 宽而矮:列表与详情并排(横屏自适应)。底部导航与悬浮加号保持不变 —— + // 它们已经是紧凑外壳的一部分,换的只是内容区的排布方式。 + if (compactTwoPane && hasList) { + return ( + +
{list}{main}
+
+ ); + } return ( diff --git a/client/electron/src/hooks/useIsNarrow.ts b/client/electron/src/hooks/useIsNarrow.ts index d54aab2..1a0c9fa 100644 --- a/client/electron/src/hooks/useIsNarrow.ts +++ b/client/electron/src/hooks/useIsNarrow.ts @@ -28,3 +28,32 @@ export function useIsNarrow(): boolean { return narrow; } + +/** + * 紧凑**双栏**判定:宽而矮的屏幕(手机横屏 844×390、小平板 1024×768 之类)。 + * + * 用户(2026-09-14):「为什么窄屏的横屏和竖屏没有自适应」。 + * + * 原因是纯宽度断点:竖屏 390 与横屏 844 **都 < 1024** ⇒ 一直是"窄屏" ⇒ + * 布局一个像素都不变,横屏多出来的 450px 全浪费在空白上。 + * 但横屏其实放得下「列表 + 详情」并排(320 + 至少 420),所以这里单独判定: + * 仍是紧凑外壳(底部导航),但内容区从"覆盖式"改成"并排式"。 + * + * 用高度而不是方向词(landscape)判:桌面窗口压扁、分屏、开软键盘都会命中, + * 而它们要的正是同一件事 —— 别浪费横向空间。 + */ +export const COMPACT_TWO_PANE_QUERY = '(min-width: 700px) and (max-height: 560px)'; + +export function useCompactTwoPane(): boolean { + const [on, setOn] = useState(() => + typeof window === 'undefined' ? false : window.matchMedia(COMPACT_TWO_PANE_QUERY).matches + ); + useEffect(() => { + const mq = window.matchMedia(COMPACT_TWO_PANE_QUERY); + const onChange = (e: MediaQueryListEvent) => setOn(e.matches); + mq.addEventListener('change', onChange); + setOn(mq.matches); + return () => mq.removeEventListener('change', onChange); + }, []); + return on; +} diff --git a/client/electron/src/main.tsx b/client/electron/src/main.tsx index 25fafab..ec31450 100644 --- a/client/electron/src/main.tsx +++ b/client/electron/src/main.tsx @@ -32,6 +32,12 @@ const syncVisibleViewport = () => { syncVisibleViewport(); window.addEventListener('resize', syncVisibleViewport, { passive: true }); +// 旋屏:iOS 上部分版本的 resize 不跟着方向变化走,显式再同步一次(并延后一帧, +// 让浏览器先把视口尺寸结算完 —— 立刻读会拿到旧高度) +window.addEventListener('orientationchange', () => { + syncVisibleViewport(); + requestAnimationFrame(syncVisibleViewport); +}, { passive: true }); window.visualViewport?.addEventListener('resize', syncVisibleViewport, { passive: true }); window.visualViewport?.addEventListener('scroll', syncVisibleViewport, { passive: true }); diff --git a/client/electron/test/manual/rotate-verify.mjs b/client/electron/test/manual/rotate-verify.mjs new file mode 100644 index 0000000..e048f87 --- /dev/null +++ b/client/electron/test/manual/rotate-verify.mjs @@ -0,0 +1,63 @@ +/** + * 横竖屏自适应验收(2026-09-14 用户:「为什么窄屏的横屏和竖屏没有自适应」)。 + * + * 缺陷本质:断点是**纯宽度**的(<1024 都算窄屏)⇒ 竖屏 390 与横屏 844 落在同一档 + * ⇒ 布局一个像素都不变,横屏多出来的宽度全浪费。看起来就是"没自适应"。 + * + * 判据:横屏必须真的改变排布(并排),竖屏必须回到覆盖式,且来回切换都不坏。 + * 用法:AGENTMAIL_DIST= ADMIN_USER=.. ADMIN_PW=.. node test/manual/rotate-verify.mjs + */ +import { openApp } from './narrow-probe-helper.mjs'; + +const { page, ctx } = await openApp({ width: 390, height: 844 }); +const results = []; +const record = (n, ok, note) => { + results.push({ n, ok }); + console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`); +}; + +const snap = () => + page.evaluate(() => { + const de = document.documentElement; + const content = document.querySelector('.narrow-shell > *:not(.narrow-nav)'); + const nav = document.querySelector('.narrow-nav')?.getBoundingClientRect(); + return { + vw: innerWidth, + vh: innerHeight, + appHeight: getComputedStyle(de).getPropertyValue('--app-height').trim(), + overflowX: de.scrollWidth - de.clientWidth, + contentCls: content ? content.className.toString().slice(0, 40) : '', + navVisible: nav ? nav.bottom <= innerHeight + 1 && nav.top > 0 : false, + navInset: nav ? Math.round(nav.left) : null + }; + }); + +try { + await page.waitForSelector('.narrow-nav', { timeout: 25000 }); + await page.waitForTimeout(1500); + const p1 = await snap(); + record('竖屏:覆盖式排布(NarrowStack)', /relative overflow-hidden/.test(p1.contentCls), p1.contentCls); + record('竖屏:--app-height 跟随视口', p1.appHeight === `${p1.vh}px`, `${p1.appHeight} vs ${p1.vh}`); + + await page.setViewportSize({ width: 844, height: 390 }); + await page.waitForTimeout(1300); + const l = await snap(); + record('横屏:真的换了排布(并排,而不是原样)', /flex-1 min-h-0 flex$/.test(l.contentCls), l.contentCls); + record('横屏:--app-height 重算(旋屏后不是旧值)', l.appHeight === '390px', l.appHeight); + record('横屏:无横向溢出', l.overflowX <= 0, `溢出 ${l.overflowX}px`); + record('横屏:底部导航仍完整可见且浮着', l.navVisible && l.navInset >= 6, `left=${l.navInset}`); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(1300); + const p2 = await snap(); + record('旋回竖屏:恢复覆盖式(可逆)', /relative overflow-hidden/.test(p2.contentCls), p2.contentCls); + record('旋回竖屏:高度重算回 844', p2.appHeight === '844px', p2.appHeight); +} finally { + await page.close(); + await ctx.close(); +} + +const failed = results.filter(r => !r.ok); +console.log(`\n 横竖屏自适应:${results.length - failed.length}/${results.length} 通过`); +if (failed.length) console.log(' 失败:' + failed.map(f => f.n).join('; ')); +process.exitCode = failed.length ? 1 : 0;