17 — 移动端适配实战

本章讲解本项目的移动端适配
重点:Chrome 100vh 坑、iOS 安全区、抽屉式侧边栏、跨组件通信。


1. 适配策略总览

断点行为
> 768px桌面布局:Sidebar 作为 flex 子项永久参与布局
≤ 768px移动布局:Sidebar 变 fixed 抽屉,点击 hamburger 滑出

项目里所有响应式 CSS 用 @media (max-width: 768px) 切分


2. 核心问题:Chrome 100vh 坑

2.1 问题

Chrome 移动端的 100vh 包含地址栏高度(约 56px),而地址栏在滚动时会收起/展开。所以用 100vh 确定的底部边界在 Chrome 里比实际可见区域高出地址栏的高度,输入框被推到屏幕之外。

夸克浏览器正确处理了动态视口高度(用了 dvh 或动态计算),所以显示正常。

2.2 修复:用 position: fixed; inset: 0 绑定真实视口

文件src/styles/global.css

1
2
3
4
5
6
7
html,
body,
#root {
position: fixed;
inset: 0;
overflow: hidden;
}

fixed + inset: 0 绑定的是浏览器真实可见区域,无论地址栏收起还是展开,四个边都跟视口重合。Chrome 和夸克的行为就一致了。

2.3 对比:三种方案

方案Chrome 行为问题
height: 100vh高度 = 屏幕 + 地址栏输入框被地址栏盖住
height: 100dvh高度 = 动态视口(不含地址栏)部分 Android 浏览器不支持
position: fixed; inset: 0绑定真实可见区域兼容性最好,行为最稳定

2.4 完整布局链(修复后)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* App.module.css */
.app {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-content);
display: flex;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
box-sizing: border-box;
}

.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
overflow: hidden;
}

.body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}

关键点

  • position: fixed 锁定整个应用到真实视口
  • box-sizing: border-box + padding: env(safe-area-inset-*) 适配 iOS 刘海/底部 home indicator
  • overflow: hidden 防止任何子元素溢出屏幕

3. iOS 安全区适配

3.1 三个安全区

变量用途典型设备
env(safe-area-inset-top)顶部刘海iPhone 14 Pro 等
env(safe-area-inset-bottom)底部 home indicator所有带底部条
env(safe-area-inset-left/right)横屏时左右iPad Pro

3.2 项目里如何用

在 global.css 预定义 CSS 变量

1
2
3
4
5
6
html {
--sat: env(safe-area-inset-top);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
}

在组件里引用(用 var() 包裹支持 fallback):

1
2
3
4
5
/* InputArea.module.css */
.area {
padding-bottom: max(var(--space-2), var(--sab, 0px));
/* ↑ 不支持时 fallback 0 */
}

max() 保证 padding 至少是 space-2(8px),有安全区时取大值。

3.3 必须在 <meta viewport>viewport-fit=cover

1
2
3
<!-- index.html -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- ↑ 关键!让页面延伸到安全区 -->

不加这个,env(safe-area-inset-*) 永远是 0。


4. 抽屉式 Sidebar(移动端核心改造)

4.1 桌面端 vs 移动端结构差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Sidebar.module.css */
.sidebar {
display: flex;
flex-direction: column;
flex-shrink: 0;
/* 公共样式 */
}

/* 桌面端:flex 子项 */
.sidebar.desktop {
position: relative;
width: var(--sidebar-width);
z-index: 2;
}
.sidebar.desktop.open { width: var(--sidebar-width); }
.sidebar.desktop.closed { width: 0; border: none; }

/* 移动端:fixed 浮层 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 86vw); /* 自适应窄屏 */
transform: translateX(-100%); /* 默认隐藏 */
}
.sidebar.open { transform: translateX(0); } /* 滑入 */
.sidebar.closed { transform: translateX(-100%); }
}

4.2 移动端专属:背景遮罩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.backdrop {
position: fixed;
inset: 0;
background: var(--bg-overlay); /* 半透明深色 */
backdrop-filter: blur(6px); /* 模糊背后内容 */
-webkit-backdrop-filter: blur(6px);
z-index: calc(var(--z-drawer) - 1); /* 在 sidebar 下方 */
}

@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.backdrop { animation: fadeIn 200ms var(--ease-out); }

关键:背景色用 --bg-overlay(50-65% 透明),不要用太深的纯色 + blur(会让背后内容完全看不见)。

4.3 z-index 层级

1
2
3
4
5
6
7
8
9
10
11
12
/* tokens.css */
:root {
--z-bg: 0; /* 背景水墨 */
--z-content: 1; /* 主内容 */
--z-topbar: 50; /* 顶栏 */
--z-drawer-backdrop: 80; /* 抽屉遮罩 */
--z-drawer: 90; /* 抽屉 */
--z-popover: 100; /* 弹出层(主题面板) */
--z-modal-backdrop: 200;
--z-modal: 210;
--z-toast: 300;
}

层级关系:

  1. 背景水墨装饰(z: 0
  2. 主内容(z: 1
  3. 顶栏(z: 50,浮在主内容之上)
  4. 抽屉背景遮罩(z: 80
  5. 抽屉(z: 90
  6. 弹出层(z: 100,主题面板)

5. 输入区移动端适配

5.1 iOS 防自动缩放

1
2
3
4
5
6
7
8
9
.area textarea {
font-size: 16px; /* iOS 在 input 字号 < 16px 时会触发自动缩放 */
}

@media (max-width: 768px) {
.area textarea {
font-size: 16px;
}
}

5.2 防止 textarea 撑破容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.area textarea {
flex: 1 1 0; /* 弹性可收缩 */
min-width: 0; /* 关键!允许 flex 子项被压缩到 0 */
box-sizing: border-box;
max-width: 100%;
}

.inputRow {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
box-sizing: border-box;
max-width: 100%;
overflow: hidden; /* 防止任何 flex 子项溢出 */
}

关键min-width: 0 在 flex 子项上至关重要 — 默认 min-width 是 auto(基于内容宽度),会导致 flex 子项即使有 flex: 1 也无法被压缩到内容宽度以下。

5.3 极小屏按钮缩小

1
2
3
4
5
@media (max-width: 380px) {
.sendBtn { width: 32px; height: 32px; } /* 36 → 32 */
.fileBtn, .inputFileIcon { width: 28px; height: 28px; }
.inputRow { padding: 8px; gap: 4px; }
}

6. Agent 选择器移动端

问题:5 个 Agent 在窄屏排成一行会溢出。

解决:横向滚动 + 隐藏滚动条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.selector {
display: flex;
gap: 4px;
padding: 8px 8px 0;
overflow-x: auto; /* 关键 */
white-space: nowrap;
max-width: 100%;
scrollbar-width: none;
}
.selector::-webkit-scrollbar { display: none; }

.item {
flex-shrink: 0; /* 防止被挤压 */
white-space: nowrap;
}

flex-shrink: 0 防止子项被压缩,overflow-x: auto 触发横向滚动。


7. 豆豆 logo 永远在屏幕最左上

7.1 桌面端 sidebar 展开时

豆豆 logo 在 Sidebar 头部(屏幕最左 = sidebar 最左)。

1
2
3
4
5
6
7
8
9
10
// Sidebar.tsx
{!isMobile && (
<div className={styles.brandRow}>
<div className={styles.brand}>
<span className={styles.brandMark}></span>
<span className={styles.brandName}>豆豆</span>
</div>
{/* 收起按钮 + 新建按钮 */}
</div>
)}

7.2 桌面端 sidebar 收起时

豆豆 logo 在 Topbar 头部(屏幕最左 = topbar 最左)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Topbar.tsx
const showTopbarLogo = isMobile || !sidebarOpen;

return (
<header className={styles.topbar}>
{showTopbarLogo && (
<div className={styles.brand}>
<span className={styles.brandMark}></span>
</div>
)}
...
</header>
);

7.3 移动端

豆豆 logo 在 Topbar 头部(移动端 sidebar 是抽屉,不参与布局)。


8. 跨组件通信(CustomEvent)

8.1 为什么需要

问题:Topbar 在 main 内,Sidebar 在 main 外。它们是兄弟组件,但都需要 sidebarOpen 状态。

不优雅的解法:提升到 App 层

1
2
3
4
5
6
7
8
// App.tsx
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<>
<Sidebar sidebarOpen={sidebarOpen} onToggleSidebar={...} />
<Topbar sidebarOpen={sidebarOpen} onToggleSidebar={...} />
</>
);

问题:组件内部 state 提升到 App 后,App 包含过多状态。

8.2 优雅解法:CustomEvent

文件themeStore.ts(也导出通信函数)

1
2
3
4
// themeStore.ts
export function dispatchOpenTheme() {
window.dispatchEvent(new CustomEvent('dodo:open-theme-switcher'));
}
1
2
3
// Topbar.tsx
import { dispatchOpenTheme } from '@/store/themeStore';
<button onClick={dispatchOpenTheme}>主题</button>
1
2
3
4
5
6
7
8
// App.tsx
useEffect(() => {
const handler = () => setThemeOpen(true);
window.addEventListener('dodo:open-theme-switcher', handler);
return () => window.removeEventListener('dodo:open-theme-switcher', handler);
}, []);

return <ThemeSwitcher open={themeOpen} onClose={() => setThemeOpen(false)} />;

8.3 项目里的两个 CustomEvent

事件名发送方接收方用途
dodo:sidebar-toggleTopbar / SidebarTopbar / Sidebar同步 sidebar 展开状态
dodo:open-theme-switcherTopbarApp打开主题切换面板

8.4 模式总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 发送方
function dispatchXxx(payload) {
window.dispatchEvent(new CustomEvent('dodo:xxx', { detail: payload }));
}

// 接收方
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ ... }>).detail;
// 处理 detail
};
window.addEventListener('dodo:xxx', handler);
return () => window.removeEventListener('dodo:xxx', handler);
}, []);

优势

  • 解耦兄弟组件
  • 不需要把状态提升到共同父级
  • 事件名带命名空间(dodo:),避免冲突

9. 移动端调试技巧

9.1 Chrome DevTools 模拟移动端

  1. F12 打开 DevTools
  2. 点击左上角手机图标(或 Ctrl+Shift+M)
  3. 选择设备(如 iPhone 14 Pro)
  4. 切换顶部地址栏模拟滚动

9.2 真实设备测试

1
2
3
4
5
6
# 让手机和电脑在同一 WiFi
# 在电脑上启动 dev server,查看 Network IP
npm run dev -- --host

# 假设电脑 IP 是 192.168.1.100,手机访问
# http://192.168.1.100:5173

9.3 vConsole(移动端调试神器)

如果想看移动端 console:

1
npm install vconsole
1
2
3
4
5
6
// main.tsx
if (import.meta.env.DEV) {
import('vconsole').then(({ default: VConsole }) => {
new VConsole();
});
}

DevTools 元素检查器在移动端不方便,vConsole 提供浮动的 console 面板。


10. 常见移动端 Bug 与修复

Bug 1:Chrome 底部输入框被地址栏盖住

已修复:用 position: fixed; inset: 0 替代 100vh

Bug 2:iOS 输入框 focus 时页面被缩放

已修复:input/textarea 字号 ≥ 16px

Bug 3:侧边栏打开时全局变磨砂玻璃

已修复:遮罩用 50-65% 半透明背景 + blur,而不是深色 + blur

Bug 4:移动端 sidebar 按钮被破坏

已修复:移动端用 @media 重写按钮大小,桌面/移动两套样式

Bug 5:Agent 选择器溢出

已修复overflow-x: auto + flex-shrink: 0


11. 一段话总结

移动端适配三大要点

  1. 真实视口position: fixed; inset: 0 替代 100vh(修 Chrome bug)
  2. iOS 安全区env(safe-area-inset-*) + viewport-fit=cover
  3. 抽屉式 Sidebar:桌面 flex 子项 / 移动 fixed 抽屉

跨组件通信:CustomEvent 解决兄弟组件状态共享,比 prop drilling 干净。
输入区防溢出flex: 1 1 0; min-width: 0 + box-sizing: border-box + overflow: hidden
iOS 防缩放:input/textarea 字号 ≥ 16px。


接下来

继续阅读 18-项目组件详解.md — 理解每个组件的代码细节。