feat(webui): 横竖屏自适应(紧凑双栏)+ 旋屏重算高度
用户:「为什么窄屏的横屏和竖屏没有自适应」。 ## 根因 断点是**纯宽度**的:`(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 条)。
This commit is contained in:
@ -28,7 +28,7 @@ import AdminUsersPage from './components/AdminUsersPage';
|
|||||||
const CalendarView = lazy(() => import('./components/CalendarView'));
|
const CalendarView = lazy(() => import('./components/CalendarView'));
|
||||||
import NarrowNav from './components/NarrowNav';
|
import NarrowNav from './components/NarrowNav';
|
||||||
import NarrowStack from './components/NarrowStack';
|
import NarrowStack from './components/NarrowStack';
|
||||||
import { useIsNarrow } from './hooks/useIsNarrow';
|
import { useCompactTwoPane, useIsNarrow } from './hooks/useIsNarrow';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const phase = useAuthStore(s => s.phase);
|
const phase = useAuthStore(s => s.phase);
|
||||||
@ -40,6 +40,8 @@ export default function App() {
|
|||||||
const resetUI = useUIStore(s => s.reset);
|
const resetUI = useUIStore(s => s.reset);
|
||||||
const narrowPane = useUIStore(s => s.narrowPane);
|
const narrowPane = useUIStore(s => s.narrowPane);
|
||||||
const narrow = useIsNarrow();
|
const narrow = useIsNarrow();
|
||||||
|
// 宽而矮(手机横屏)时紧凑外壳内容区改并排,见 useCompactTwoPane 的说明
|
||||||
|
const compactTwoPane = useCompactTwoPane();
|
||||||
const commTab = useUIStore(s => s.commTab);
|
const commTab = useUIStore(s => s.commTab);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -219,6 +221,15 @@ export default function App() {
|
|||||||
</NarrowShell>
|
</NarrowShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 宽而矮:列表与详情并排(横屏自适应)。底部导航与悬浮加号保持不变 ——
|
||||||
|
// 它们已经是紧凑外壳的一部分,换的只是内容区的排布方式。
|
||||||
|
if (compactTwoPane && hasList) {
|
||||||
|
return (
|
||||||
|
<NarrowShell>
|
||||||
|
<div className="flex-1 min-h-0 flex">{list}{main}</div>
|
||||||
|
</NarrowShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<NarrowShell>
|
<NarrowShell>
|
||||||
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
|
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
|
||||||
|
|||||||
@ -28,3 +28,32 @@ export function useIsNarrow(): boolean {
|
|||||||
|
|
||||||
return narrow;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -32,6 +32,12 @@ const syncVisibleViewport = () => {
|
|||||||
|
|
||||||
syncVisibleViewport();
|
syncVisibleViewport();
|
||||||
window.addEventListener('resize', syncVisibleViewport, { passive: true });
|
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('resize', syncVisibleViewport, { passive: true });
|
||||||
window.visualViewport?.addEventListener('scroll', syncVisibleViewport, { passive: true });
|
window.visualViewport?.addEventListener('scroll', syncVisibleViewport, { passive: true });
|
||||||
|
|
||||||
|
|||||||
63
client/electron/test/manual/rotate-verify.mjs
Normal file
63
client/electron/test/manual/rotate-verify.mjs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 横竖屏自适应验收(2026-09-14 用户:「为什么窄屏的横屏和竖屏没有自适应」)。
|
||||||
|
*
|
||||||
|
* 缺陷本质:断点是**纯宽度**的(<1024 都算窄屏)⇒ 竖屏 390 与横屏 844 落在同一档
|
||||||
|
* ⇒ 布局一个像素都不变,横屏多出来的宽度全浪费。看起来就是"没自适应"。
|
||||||
|
*
|
||||||
|
* 判据:横屏必须真的改变排布(并排),竖屏必须回到覆盖式,且来回切换都不坏。
|
||||||
|
* 用法:AGENTMAIL_DIST=<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;
|
||||||
Reference in New Issue
Block a user