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;