You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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.ts
  • packages/bolt-ui/components/Drawer/src/Drawer.vue
  • packages/bolt-ui/components/Drawer/src/useDrawer.ts
  • packages/bolt-ui/components/Drawer/style/css.ts
  • packages/bolt-ui/components/Drawer/style/index.ts
  • packages/bolt-ui/theme-chalk/src/components/drawer/_index.scss
  • packages/bolt-ui/theme-chalk/src/components/drawer/drawer-left.scss
  • packages/bolt-ui/theme-chalk/src/components/drawer/drawer-right.scss
  • packages/bolt-ui/theme-chalk/src/components/drawer/mask.scss
  • packages/bolt-ui/composables/useMediaQuery.ts

修改:

  • packages/bolt-ui/components/index.ts — 注册 Drawer、useMediaQuery
  • packages/bolt-ui/README.md — 追加 Drawer 章节
  • app/components/index/LeftSidebar.vue — 新增 mode / drawerOpen / width / side props,对应 emits
  • app/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(visibleonClickzIndex)。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、ctxVisibletree-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-actions 3 个 action-btn 缩小为 32×32 圆按钮并隐藏 "新增" 文字标签(保留 + 图标)
  • 第 2 行:.header-titlefont-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) 关闭

状态联动

  • 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% 回归

单元测试(如时间允许)

  • BoDrawerv-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 配合)
  • 抽屉内容懒加载(首次打开才渲染分类树)