12 KiB
首页移动端适配设计
概述
首页当前在 <640px 断点下表现糟糕:左侧栏被压缩为顶部 260px 高的横条、header 突然改为纵向堆叠、瀑布流列数硬切。本设计在保持桌面端 100% 行为不变的前提下,给首页增加完整的移动端体验,并把可复用部分抽离到 bolt-ui 组件库。
范围
- 改造
app/pages/index/index.vue的移动端布局 - 改造
app/components/index/LeftSidebar.vue支持 rail / drawer 双模式 - 新增
packages/bolt-ui/components/Drawer/通用抽屉组件 - 新增
packages/bolt-ui/composables/useMediaQuery.ts通用响应式监听
不在范围内
- admin / photo / portfolio / about / auth 等其他页面的适配(虽然 Drawer 组件是可复用的,但本任务不重做这些页面)
- 触屏手势(滑动关闭抽屉、双指缩放等)
- 离线 / PWA 改造
- 性能优化(瀑布流懒加载逻辑、IntersectionObserver 行为保持不变)
架构
改动文件
新增:
packages/bolt-ui/components/Drawer/index.tspackages/bolt-ui/components/Drawer/src/Drawer.vuepackages/bolt-ui/components/Drawer/src/useDrawer.tspackages/bolt-ui/components/Drawer/style/css.tspackages/bolt-ui/components/Drawer/style/index.tspackages/bolt-ui/theme-chalk/src/components/drawer/_index.scsspackages/bolt-ui/theme-chalk/src/components/drawer/drawer-left.scsspackages/bolt-ui/theme-chalk/src/components/drawer/drawer-right.scsspackages/bolt-ui/theme-chalk/src/components/drawer/mask.scsspackages/bolt-ui/composables/useMediaQuery.ts
修改:
packages/bolt-ui/components/index.ts— 注册 Drawer、useMediaQuerypackages/bolt-ui/README.md— 追加 Drawer 章节app/components/index/LeftSidebar.vue— 新增mode/drawerOpen/width/sideprops,对应 emitsapp/pages/index/index.vue— 接入 useMediaQuery、BoDrawer、header 三行布局app/components/TopNav.vue— 实际不需要改动(见 § 样式细节 / TopNav 协调)
组件关系
pages/index/index.vue
└─ IndexLeftSidebar
├─ mode === 'rail' → <aside class="left-sidebar"> (原状)
└─ mode === 'drawer' → <BoDrawer v-model:open>
└─ default slot: <aside class="left-sidebar"> (同上)
mode 由父组件根据 useMediaQuery('(max-width: 767.98px)') 决定。drawerOpen 仅在 drawer 模式有意义,rail 模式下忽略。
bolt-ui/Drawer 设计
API
interface DrawerProps {
open: boolean // v-model:open
side?: 'left' | 'right' = 'left'
width?: string | number = '80%' // CSS length;数字视为 px
closeOnMask?: boolean = true
closeOnEsc?: boolean = true
showMask?: boolean = true
maskClass?: string
zIndex?: number = 200 // 高于 TopNav 的 100
transitionDuration?: number = 280 // ms
teleportTo?: string = 'body'
}
defineEmits<{ 'update:open': [v: boolean] }>()
defineSlots<{ default(): VNode }>()
行为
- 基于现有
Mask组件渲染遮罩 - 抽屉面板从
side一侧滑入;CSS transition 监听transform - 打开时
document.body.style.overflow = 'hidden',关闭时恢复 - 监听
keydown.esc关闭(仅当面板可见时) - 使用
v-show保留 DOM,关闭后不销毁子组件 - Teleport 到
body,与父组件的overflow: hidden/transform解耦 - SSR 兼容:
onMounted之前不渲染 Teleport
主题变量
:root {
--bo-drawer-bg: var(--color-canvas);
--bo-drawer-shadow: 0 8px 32px rgba(20, 20, 19, 0.12);
--bo-drawer-z-index: 200;
--bo-drawer-transition: 280ms cubic-bezier(0.4, 0, 0.2, 1);
}
A11y
- 面板元素
role="dialog"aria-modal="true"aria-label="侧边栏" - 打开时焦点移到面板内第一个可聚焦元素
- 关闭时焦点返回触发器(通过
:data-drawer-trigger引用 +previousActiveElement记忆) - Tab / Shift+Tab 在面板内循环(focus trap)
与 Mask 的关系
Mask 组件已经存在,本设计复用其 props(visible、onClick、zIndex)。Drawer 不重新发明遮罩层,只把它包进 Teleport 内。
LeftSidebar 模式切换
新增 Props
interface LeftSidebarProps {
// 原有
tools?: ToolItem[]
categories: CategoryNode[]
activeToolKey?: string
activeCategoryId?: string
// 新增
mode: 'rail' | 'drawer'
drawerOpen: boolean
width?: string = '300px'
side?: 'left' | 'right' = 'left'
}
新增 Emits
defineEmits<{
// 原有
selectTool: [key: string]
selectCategory: [id: string]
addCategory: [parentId: string | null]
renameCategory: [id: string]
deleteCategory: [id: string]
moveCategory: [id: string]
changeCover: [id: string]
// 新增
'update:drawerOpen': [v: boolean]
}>()
模板分支
<template>
<aside v-if="mode === 'rail'" class="left-sidebar">
<!-- 原 rail 内容 -->
</aside>
<BoDrawer
v-else
:open="drawerOpen"
:side="side"
:width="width"
@update:open="emit('update:drawerOpen', $event)"
>
<aside class="left-sidebar" :data-drawer-trigger="triggerId">
<!-- 同一份内容,分类展开集合 / 滚动位置在切换 mode 时由 v-show 保留 -->
</aside>
</BoDrawer>
</template>
状态保留
由于 IndexLeftSidebar 内部状态(expandedIds Set、ctxVisible、tree-scroll 滚动位置)全部在组件内,rail 与 drawer 共享同一份组件实例,因此切换 mode 时不会重置。仅在父组件销毁整个组件(如跳转到其他页)时才丢失。
上下文菜单
现有 IndexContextMenu 已用 Teleport 到 body 渲染,ctxX/ctxY 记录 MouseEvent.clientX/Y,drawer 面板内的右键事件天然兼容。
首页集成
响应式状态
const isMobile = useMediaQuery('(max-width: 767.98px)')
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
const drawerOpen = ref(false)
isMobile 在 SSR 阶段为 false(避免 hydration mismatch),onMounted 后由 matchMedia 真实驱动。matchMedia.change 事件触发响应式更新。
Header 三行布局(< 768px)
┌─────────────────────────────────────────┐
│ 分类 [⌘][⛯][+]│ ← eyebrow + 动作按钮(图标)
│ 标题 │ ← 标题(小字号)
│ [≡ 分类] 当前分类名 │ ← 抽屉触发 + 当前分类
└─────────────────────────────────────────┘
具体规则:
- 第 1 行:
.header-eyebrow文本左对齐,.header-actions3 个 action-btn 缩小为 32×32 圆按钮并隐藏 "新增" 文字标签(保留+图标) - 第 2 行:
.header-title,font-size: clamp(22px, 7vw, 30px) - 第 3 行(新增):「≡ 分类」按钮(hairline 描边风格,圆角 md,44×44 touch target)+ 紧邻的 chip 显示当前分类名
- chip:
.surface-card背景 +.ink文字 +.body字体 - 点击「≡ 分类」→
drawerOpen = true - drawer 内选中分类 → 自动 emit
update:drawerOpen(false)关闭
- chip:
状态联动
selectCategory处理器内部:保留原activeCategoryId.value = id逻辑;然后if (drawerOpen.value) drawerOpen.value = false。这样无论当前是 rail 还是 drawer 模式都安全,rail 模式下赋值是 no-op。selectTool不关闭抽屉(用户可能想连续切换工具)
滚动
- 现有
IntersectionObserver+sentinel触底加载逻辑不变 - Drawer 打开时
body滚动锁定由 BoDrawer 内部处理;瀑布流不会"穿透"滚动到底
样式细节
Breakpoint 重写
app/pages/index/index.vue 移除现有 @media (max-width: 900px) 与 (max-width: 640px),替换为:
@media (max-width: 767.98px) {
// 移动端:侧栏通过 mode='drawer' 隐藏 rail 节点;header 三行;瀑布 1 列
.home-layout { grid-template-columns: 1fr; } // 抽屉不占 grid 列
.home-main { padding: 16px 16px 32px; }
.main-header { flex-direction: column; align-items: stretch; gap: 12px; }
.action-btn { width: 32px; height: 32px; padding: 0; }
.action-btn span { display: none; } // 隐藏"新增"文字
.header-title { font-size: clamp(22px, 7vw, 30px); }
}
@media (min-width: 768px) and (max-width: 1023.98px) {
// 平板:侧栏保持 rail 240px;瀑布 2 列
.home-layout { grid-template-columns: 240px 1fr; }
.home-main { padding: 20px 16px 32px; }
}
@media (min-width: 1024px) and (max-width: 1439.98px) {
// 桌面:现状
}
@media (min-width: 1440px) {
// 宽屏:现状
}
Masonry 列数(沿用 decideColumnCount,阈值微调)
| 容器宽度 | 列数 |
|---|---|
<520px |
1 |
<900px |
2 |
<1200px |
3 |
<1600px |
4 |
≥1600px |
5 |
Touch target
<768px 下所有可点击元素最小 40×40;分类抽屉触发按钮 44×44。
TopNav 协调
TopNav 当前 z-index: 100,Drawer 默认 z-index: 200,Drawer 自然浮在 TopNav 之上,TopNav 汉堡菜单与 Drawer 互不干扰。无需调整 TopNav。
useMediaQuery
packages/bolt-ui/composables/useMediaQuery.ts
export function useMediaQuery(query: string): Ref<boolean> {
const matches = ref(false)
let mql: MediaQueryList | null = null
onMounted(() => {
mql = window.matchMedia(query)
matches.value = mql.matches
const handler = (e: MediaQueryListEvent) => { matches.value = e.matches }
mql.addEventListener('change', handler)
onBeforeUnmount(() => mql.removeEventListener('change', handler))
})
return matches
}
SSR 安全(无 window 访问);组件卸载自动清理监听。
验证
手工测试矩阵
| 设备 | 视口 | 期望 |
|---|---|---|
| iPhone SE | 375×667 | 单列瀑布、header 三行、侧栏抽屉触发可见 |
| iPhone 14 Pro | 393×852 | 同上 + safe area 适配 |
| iPad Mini (portrait) | 768×1024 | 双列瀑布、rail 侧栏 240px |
| iPad Mini (landscape) | 1024×768 | 桌面布局回归 |
| iPad Pro 11" | 834×1194 | 平板布局 |
| Desktop | 1440×900 | 原状回归 |
| Wide | 1920×1080 | 原状回归 |
检查清单
- 移动端侧栏不再以顶部横条形式出现
- 抽屉可开可关:点击触发器、点击遮罩、按 Esc 都能关闭
- 抽屉内选中分类后主视图内容切换、抽屉自动关闭
- 滚动到底部正常加载更多
- 抽屉打开时 body 不滚动、背景不"穿透"
- portrait ↔ landscape 旋转布局正确切换、无组件残留
- 桌面端所有现有功能 100% 回归
单元测试(如时间允许)
BoDrawer:v-model:open同步、Esc 关闭、遮罩点击关闭、body overflow 锁定/恢复useMediaQuery:mock matchMedia,验证响应式更新
风险与权衡
- 新 bolt-ui 组件的回归风险:Mask 已存在,Drawer 基于其构建;首次对外暴露的 A11y(focus trap、aria 属性)需手工 + 单元测试双覆盖
- SSR hydration:useMediaQuery 在 SSR 返回 false,客户端首次 mount 后才更新;这意味着移动端用户首次打开页面会看到 0.1s 桌面布局再切换为 drawer。这是可接受的轻微闪烁
- 左抽屉与右抽屉的扩展性:当前只实现 left,但 side prop 已预留 right,admin 等页面未来使用右侧抽屉(设置/通知)时无需改动组件
后续工作(不在本次)
- 其他页面(admin、photo、portfolio、about)适配移动端
- 触屏手势(滑动关闭抽屉)
- 暗色模式(与 DESIGN.md 中的 surface-dark 配合)
- 抽屉内容懒加载(首次打开才渲染分类树)