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.
 
 
 
 

29 KiB

首页移动端适配实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 改造首页在 <768px 视口下的布局与交互:左侧栏改为抽屉触发,header 三行布局,瀑布流列数与现有阈值一致,桌面端 100% 回归。

Architecture: 新增 bolt-ui/components/Drawer 通用抽屉组件(基于现有 BoMask),新增 bolt-ui/hooks/useMediaQuery 响应式 composable;IndexLeftSidebar 增加 mode: 'rail' | 'drawer' 切换;首页用 useMediaQuery 驱动 mode 与 drawer 状态。

Tech Stack: Vue 3.5, Nuxt 4.4, bolt-ui (SCSS + useNamespace pattern), @nuxt/icon, vue3-toastify

Conventions to follow:

  • bolt-ui 组件:components/<Name>/index.ts + src/<Name>.vue + style/{index.ts,css.ts} + theme-chalk/src/<kebab>.scss
  • 命名空间:useNamespace('drawer')bo-drawer, bo-drawer__x, bo-drawer--x
  • 注册:withInstall(_Drawer)<BoDrawer> 全局可用(由 bolt-ui/nuxt 模块自动注册)
  • Composable:hooks/<name>/index.ts → 自动导入为 useXxx
  • 锁定版本:禁止升级依赖;如需新增,手动 bun add

文件结构

packages/bolt-ui/
├── components/
│   ├── Drawer/                                  [NEW]
│   │   ├── index.ts
│   │   ├── src/
│   │   │   └── Drawer.vue
│   │   └── style/
│   │       ├── index.ts
│   │       └── css.ts
│   └── index.ts                                 [MODIFY] 添加 export * from './Drawer'
├── hooks/
│   └── useMediaQuery/                           [NEW]
│       └── index.ts
├── theme-chalk/src/
│   ├── components/drawer/                       [NEW]
│   │   ├── _index.scss                          (主入口 @use 子文件)
│   │   ├── _var.scss
│   │   ├── panel.scss
│   │   ├── panel-left.scss
│   │   ├── panel-right.scss
│   │   ├── mask.scss
│   │   └── anim/
│   │       ├── _var.scss
│   │       ├── panel-left-fade.scss
│   │       ├── panel-right-fade.scss
│   │       └── mask-fade.scss
│   └── drawer.scss                              [NEW] 主入口(被 bolt-ui/nuxt 自动注入)
└── README.md                                    [MODIFY] 追加 Drawer 章节

app/
├── components/
│   └── index/LeftSidebar.vue                    [MODIFY] 新增 mode/drawerOpen 等 props
└── pages/index/index.vue                        [MODIFY] drawerOpen 状态、header 三行、断点重写

Task 1: useMediaQuery composable

Files:

  • Create: packages/bolt-ui/hooks/useMediaQuery/index.ts

  • Step 1: 写 composable

import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'

/**
 * 响应式地跟踪 CSS 媒体查询匹配状态。
 *
 * SSR 安全:服务端渲染期间始终返回 `false`,避免 hydration mismatch;
 * 客户端 `onMounted` 之后才订阅 `matchMedia`。
 *
 * @param query - 合法 CSS media query 字符串
 * @returns Ref<boolean> - 当前是否匹配
 */
export function useMediaQuery(query: string): Ref<boolean> {
  const matches = ref(false)
  let mql: MediaQueryList | null = null
  const handler = (e: MediaQueryListEvent) => {
    matches.value = e.matches
  }

  onMounted(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return
    mql = window.matchMedia(query)
    matches.value = mql.matches
    mql.addEventListener('change', handler)
  })

  onBeforeUnmount(() => {
    mql?.removeEventListener('change', handler)
  })

  return matches
}
  • Step 2: 提交
git add packages/bolt-ui/hooks/useMediaQuery/index.ts
git -c commit.gpgsign=false commit -m "feat(bolt-ui): add useMediaQuery composable"

备注:bolt-ui/nuxt 模块会自动扫描 hooks/<name>/index.ts 并把 useXxx 导出注册为 auto-import,所以无需修改 nuxt.ts


Task 2: BoDrawer 组件脚手架(props/emits/template/状态)

Files:

  • Create: packages/bolt-ui/components/Drawer/src/Drawer.vue

  • Create: packages/bolt-ui/components/Drawer/index.ts

  • Step 1: 写 Drawer.vue 模板 + props + emits + 基础状态

packages/bolt-ui/components/Drawer/src/Drawer.vue:

<template>
  <Teleport :to="teleportTo" :disabled="!mounted">
    <Transition :name="maskTransition" appear>
      <div
        v-if="showMask && isOpen"
        :class="e('mask')"
        @click="onMaskClick"
      />
    </Transition>

    <Transition :name="panelTransition" appear @after-leave="onAfterLeave">
      <div
        v-show="isOpen"
        ref="panelRef"
        :class="panelClasses"
        :style="panelStyle"
        role="dialog"
        aria-modal="true"
        :aria-label="ariaLabel"
        @keydown="onKeydown"
      >
        <slot />
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useNamespace } from 'bolt-ui/utils/hooks/use-namespace'

const props = withDefaults(
  defineProps<{
    open: boolean
    side?: 'left' | 'right'
    width?: string | number
    closeOnMask?: boolean
    closeOnEsc?: boolean
    showMask?: boolean
    zIndex?: number
    transitionDuration?: number
    teleportTo?: string
    ariaLabel?: string
  }>(),
  {
    side: 'left',
    width: '80%',
    closeOnMask: true,
    closeOnEsc: true,
    showMask: true,
    zIndex: 200,
    transitionDuration: 280,
    teleportTo: 'body',
    ariaLabel: '抽屉',
  },
)

const emit = defineEmits<{
  'update:open': [v: boolean]
}>()

const { b, e, m } = useNamespace('drawer')

const mounted = ref(false)
const panelRef = ref<HTMLElement | null>(null)
const isOpen = ref(props.open)
let prevOverflow = ''
let lastFocusedTrigger: HTMLElement | null = null

const panelClasses = computed(() => [b(), m(props.side)])
const panelStyle = computed(() => ({
  width: typeof props.width === 'number' ? `${props.width}px` : props.width,
  zIndex: String(props.zIndex),
  transitionDuration: `${props.transitionDuration}ms`,
}))
const panelTransition = computed(() => `bo-drawer-panel-${props.side}-fade`)
const maskTransition = 'bo-drawer-mask-fade'

watch(
  () => props.open,
  async (next) => {
    isOpen.value = next
    if (next) {
      lastFocusedTrigger = (document.activeElement as HTMLElement | null) ?? null
      prevOverflow = document.body.style.overflow
      document.body.style.overflow = 'hidden'
      await nextTick()
      focusFirst()
    } else {
      document.body.style.overflow = prevOverflow
      lastFocusedTrigger?.focus?.()
    }
  },
  { immediate: true },
)

function onMaskClick() {
  if (props.closeOnMask) emit('update:open', false)
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && props.closeOnEsc) {
    emit('update:open', false)
    e.stopPropagation()
  }
  if (e.key === 'Tab') trapFocus(e)
}

function onAfterLeave() {
  // 关闭后保留 DOM(v-show)— 此回调仅用于潜在扩展
}

function focusFirst() {
  const root = panelRef.value
  if (!root) return
  const focusable = getFocusable(root)[0]
  focusable?.focus?.()
}

function getFocusable(root: HTMLElement): HTMLElement[] {
  const sel = 'a[href],button:not([disabled]),[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
  return Array.from(root.querySelectorAll<HTMLElement>(sel))
}

function trapFocus(e: KeyboardEvent) {
  const root = panelRef.value
  if (!root) return
  const focusable = getFocusable(root)
  if (focusable.length === 0) {
    e.preventDefault()
    return
  }
  const first = focusable[0]!
  const last = focusable[focusable.length - 1]!
  const active = document.activeElement as HTMLElement | null
  if (e.shiftKey && active === first) {
    e.preventDefault()
    last.focus()
  } else if (!e.shiftKey && active === last) {
    e.preventDefault()
    first.focus()
  }
}

onMounted(() => {
  mounted.value = true
})

onBeforeUnmount(() => {
  document.body.style.overflow = prevOverflow
})

defineOptions({ name: 'BoDrawer' })
</script>
  • Step 2: 写 index.ts

packages/bolt-ui/components/Drawer/index.ts:

import _Drawer from './src/Drawer.vue'
import { withInstall } from 'bolt-ui/utils/vue/install'

export type BoDrawer = InstanceType<typeof _Drawer>

export const BoDrawer = withInstall(_Drawer)
export default BoDrawer
  • Step 3: 在 components/index.ts 注册导出

编辑 packages/bolt-ui/components/index.ts,在末尾追加:

export * from './Drawer'
  • Step 4: 提交
git add packages/bolt-ui/components/Drawer packages/bolt-ui/components/index.ts
git -c commit.gpgsign=false commit -m "feat(bolt-ui): add BoDrawer component scaffold"

Task 3: BoDrawer 主题样式(panel + mask + 动画)

Files:

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/_var.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/panel.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/panel-left.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/panel-right.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/mask.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/anim/_var.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/anim/panel-left-fade.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/anim/panel-right-fade.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/anim/mask-fade.scss

  • Create: packages/bolt-ui/theme-chalk/src/components/drawer/_index.scss

  • Create: packages/bolt-ui/theme-chalk/src/drawer.scss

  • Create: packages/bolt-ui/components/Drawer/style/index.ts

  • Create: packages/bolt-ui/components/Drawer/style/css.ts

  • Step 1: 写 SCSS 变量与动画基础

packages/bolt-ui/theme-chalk/src/components/drawer/anim/_var.scss:

$drawer-time: 0.28s !default;

packages/bolt-ui/theme-chalk/src/components/drawer/anim/mask-fade.scss:

@use "_var" as *;

.bo-drawer-mask-fade-enter-active,
.bo-drawer-mask-fade-leave-active {
  transition: opacity $drawer-time ease;
}

.bo-drawer-mask-fade-enter-from,
.bo-drawer-mask-fade-leave-to {
  opacity: 0;
}

packages/bolt-ui/theme-chalk/src/components/drawer/anim/panel-left-fade.scss:

@use "_var" as *;

.bo-drawer-panel-left-fade-enter-active,
.bo-drawer-panel-left-fade-leave-active {
  transition: transform $drawer-time cubic-bezier(0.4, 0, 0.2, 1);
}

.bo-drawer-panel-left-fade-enter-from,
.bo-drawer-panel-left-fade-leave-to {
  transform: translateX(-100%);
}

packages/bolt-ui/theme-chalk/src/components/drawer/anim/panel-right-fade.scss:

@use "_var" as *;

.bo-drawer-panel-right-fade-enter-active,
.bo-drawer-panel-right-fade-leave-active {
  transition: transform $drawer-time cubic-bezier(0.4, 0, 0.2, 1);
}

.bo-drawer-panel-right-fade-enter-from,
.bo-drawer-panel-right-fade-leave-to {
  transform: translateX(100%);
}
  • Step 2: 写 panel / mask 样式

packages/bolt-ui/theme-chalk/src/components/drawer/_var.scss:

$bo-drawer-bg: var(--color-canvas) !default;
$bo-drawer-shadow: 0 8px 32px rgba(20, 20, 19, 0.12) !default;
$bo-drawer-mask-bg: rgba(20, 20, 19, 0.32) !default;

packages/bolt-ui/theme-chalk/src/components/drawer/panel.scss:

@use "./_var" as *;

.bo-drawer {
  position: fixed;
  top: 0;
  bottom: 0;
  background: $bo-drawer-bg;
  box-shadow: $bo-drawer-shadow;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  outline: none;
  -webkit-overflow-scrolling: touch;
}

packages/bolt-ui/theme-chalk/src/components/drawer/panel-left.scss:

.bo-drawer--left {
  left: 0;
}

packages/bolt-ui/theme-chalk/src/components/drawer/panel-right.scss:

.bo-drawer--right {
  right: 0;
}

packages/bolt-ui/theme-chalk/src/components/drawer/mask.scss:

@use "./_var" as *;

.bo-drawer__mask {
  position: fixed;
  inset: 0;
  background: $bo-drawer-mask-bg;
  z-index: 199; // 比 panel 低 1
  backdrop-filter: blur(2px);
}
  • Step 3: 写组件目录入口

packages/bolt-ui/theme-chalk/src/components/drawer/_index.scss:

@use "./anim/mask-fade.scss";
@use "./anim/panel-left-fade.scss";
@use "./anim/panel-right-fade.scss";
@use "./panel.scss";
@use "./panel-left.scss";
@use "./panel-right.scss";
@use "./mask.scss";
  • Step 4: 写主题根入口(被 bolt-ui/nuxt 自动注入)

packages/bolt-ui/theme-chalk/src/drawer.scss:

@use "components/drawer/_index.scss";
  • Step 5: 写 css bridge 文件

packages/bolt-ui/components/Drawer/style/index.ts:

import 'bolt-ui/theme-chalk/src/drawer.scss'

packages/bolt-ui/components/Drawer/style/css.ts:

import 'bolt-ui/theme-chalk/src/drawer.scss'
  • Step 6: 提交
git add packages/bolt-ui/theme-chalk/src/components/drawer packages/bolt-ui/theme-chalk/src/drawer.scss packages/bolt-ui/components/Drawer/style
git -c commit.gpgsign=false commit -m "feat(bolt-ui): add Drawer theme styles and css bridge"

备注:bolt-ui/nuxt 模块会扫描 theme-chalk/src/drawer.scss 并按需注入到使用 BoDrawer 的 .vue/.ts/.tsx 文件。无需手动 import。


Task 4: LeftSidebar 增加 mode 与 drawer 模式分支

Files:

  • Modify: app/components/index/LeftSidebar.vue(props、emits、template 顶部)

  • Step 1: 扩展 props 类型

编辑 app/components/index/LeftSidebar.vue<script setup>,把现有:

const props = withDefaults(defineProps<{
  tools?: ToolItem[]
  categories: CategoryNode[]
  activeToolKey?: string
  activeCategoryId?: string
}>(), { ... })

替换为:

const props = withDefaults(defineProps<{
  tools?: ToolItem[]
  categories: CategoryNode[]
  activeToolKey?: string
  activeCategoryId?: string
  mode?: 'rail' | 'drawer'
  drawerOpen?: boolean
  width?: string
  side?: 'left' | 'right'
}>(), {
  mode: 'rail',
  drawerOpen: false,
  width: '300px',
  side: 'left',
  tools: () => [
    { key: 'home', label: '主页', icon: 'lucide:home' },
    { key: 'search', label: '搜索', icon: 'lucide:search' },
    { key: 'tags', label: '标签', icon: 'lucide:tag' },
    { key: 'highlights', label: '高亮', icon: 'lucide:highlighter' },
    { key: 'archive', label: '归档', icon: 'lucide:archive' },
  ],
})
  • Step 2: 扩展 emits

把现有 defineEmits<{ ... }>() 块替换为:

const emit = 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]
}>()
  • Step 3: 抽出 aside 内容为 template fragment

<template> 顶部添加 <template #rail> 把现有的 <aside> 包起来(注意 <aside> 内部保持原状)。先在 <aside class="left-sidebar"> 那一行的最外层包一层 fragment。

app/components/index/LeftSidebar.vue 模板部分,从:

<template>
  <aside class="left-sidebar">
    <!-- ...原内容... -->
  </aside>
</template>

改为:

<template>
  <template v-if="mode === 'rail'">
    <aside class="left-sidebar">
      <!-- ...原内容(保持不变)... -->
    </aside>
  </template>

  <BoDrawer
    v-else
    :open="drawerOpen"
    :side="side"
    :width="width"
    :z-index="200"
    @update:open="(v) => emit('update:drawerOpen', v)"
  >
    <aside class="left-sidebar">
      <!-- ...同一份内容保持不变... -->
    </aside>
  </BoDrawer>
</template>

复制现有 <aside class="left-sidebar">...</aside> 的内部到 drawer 模式的 <aside> 里。代码重复是可接受的——Vue 模板 v-if/v-else 之间不共享 fragment。

  • Step 4: 提交
git add app/components/index/LeftSidebar.vue
git -c commit.gpgsign=false commit -m "feat(index): support rail/drawer mode in LeftSidebar"

备注:rail 模式下不需要 BoDrawer,所以走 v-if;drawer 模式下整个 aside 包进 BoDrawer。BoDrawer 已在 Task 2/3 中定义且由 bolt-ui/nuxt 自动注册,无需 import。


Task 5: 首页 — useMediaQuery + drawerOpen 状态

Files:

  • Modify: app/pages/index/index.vue(script 段 + template 顶部)

  • Step 1: 在 script 段顶部添加状态与媒体查询

app/pages/index/index.vue<script setup> 顶部,const { $toast } = useNuxtApp() 这一行之后,添加

const isMobile = useMediaQuery('(max-width: 767.98px)')
const drawerOpen = ref(false)
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
  • Step 2: 把 drawerOpen 关闭逻辑加进 onSelectCategory

定位 function onSelectCategory(id: string) { ... } 这一函数,把现有函数体替换为:

function onSelectCategory(id: string) {
  activeCategoryId.value = id
  if (drawerOpen.value) drawerOpen.value = false
}
  • Step 3: 更新 IndexLeftSidebar 的 props

定位模板中:

<IndexLeftSidebar
  class="home-sidebar"
  :categories="categories"
  :active-tool-key="activeToolKey"
  :active-category-id="activeCategoryId"
  @select-tool="onSelectTool"
  @select-category="onSelectCategory"
  @add-category="onAddCategory"
  @rename-category="onRenameCategory"
  @delete-category="onDeleteCategory"
  @move-category="onMoveCategory"
  @change-cover="onChangeCover"
/>

替换为:

<IndexLeftSidebar
  v-if="sidebarMode === 'rail'"
  class="home-sidebar"
  :mode="sidebarMode"
  :categories="categories"
  :active-tool-key="activeToolKey"
  :active-category-id="activeCategoryId"
  @select-tool="onSelectTool"
  @select-category="onSelectCategory"
  @add-category="onAddCategory"
  @rename-category="onRenameCategory"
  @delete-category="onDeleteCategory"
  @move-category="onMoveCategory"
  @change-cover="onChangeCover"
/>

<IndexLeftSidebar
  v-else
  :mode="sidebarMode"
  :drawer-open="drawerOpen"
  :categories="categories"
  :active-tool-key="activeToolKey"
  :active-category-id="activeCategoryId"
  @update:drawer-open="(v) => drawerOpen = v"
  @select-tool="onSelectTool"
  @select-category="onSelectCategory"
  @add-category="onAddCategory"
  @rename-category="onRenameCategory"
  @delete-category="onDeleteCategory"
  @move-category="onMoveCategory"
  @change-cover="onChangeCover"
/>

备注:v-if="sidebarMode === 'rail'" 是为了让桌面端不渲染 drawer 节点;移动端通过 v-else 渲染。useMediaQuery 在 SSR 返回 false,所以服务端渲染 rail 节点;客户端 mount 后若实际是移动端,会切到 drawer 节点。这是可接受的 0.1s 闪烁。

  • Step 4: 提交
git add app/pages/index/index.vue
git -c commit.gpgsign=false commit -m "feat(index): wire useMediaQuery and drawer state"

Task 6: 首页 — header 三行布局(drawer 触发 + 当前分类 chip)

Files:

  • Modify: app/pages/index/index.vue(template <header class="main-header"> 块)

  • Step 1: 重写 main-header 模板

把现有:

<header class="main-header">
  <div class="header-left">
    <span class="header-eyebrow">分类</span>
    <h1 class="header-title">{{ activeCategoryName }}</h1>
  </div>
  <div class="header-actions">
    <button type="button" class="action-btn" @click="$toast.info('视图切换即将上线')">
      <Icon name="lucide:layout-grid" />
    </button>
    <button type="button" class="action-btn" @click="$toast.info('筛选即将上线')">
      <Icon name="lucide:sliders-horizontal" />
    </button>
    <button type="button" class="action-btn primary" @click="$toast.info('上传即将上线')">
      <Icon name="lucide:plus" />
      <span>新增</span>
    </button>
  </div>
</header>

替换为:

<header class="main-header">
  <div class="header-row header-row--top">
    <span class="header-eyebrow">分类</span>
    <div class="header-actions">
      <button type="button" class="action-btn action-btn--icon" aria-label="视图" @click="$toast.info('视图切换即将上线')">
        <Icon name="lucide:layout-grid" />
      </button>
      <button type="button" class="action-btn action-btn--icon" aria-label="筛选" @click="$toast.info('筛选即将上线')">
        <Icon name="lucide:sliders-horizontal" />
      </button>
      <button type="button" class="action-btn action-btn--icon action-btn--primary" aria-label="新增" @click="$toast.info('上传即将上线')">
        <Icon name="lucide:plus" />
      </button>
    </div>
  </div>

  <h1 class="header-title">{{ activeCategoryName }}</h1>

  <div v-if="isMobile" class="header-row header-row--bottom">
    <button
      type="button"
      class="drawer-trigger"
      aria-label="打开分类抽屉"
      @click="drawerOpen = true"
    >
      <Icon name="lucide:menu" />
      <span>分类</span>
    </button>
    <span class="category-chip">{{ activeCategoryName }}</span>
  </div>
</header>
  • Step 2: 添加配套 CSS

定位 <style scoped> 块,在末尾追加(不要修改现有规则):

/* ── Mobile header rows ── */

.header-row {
  display: flex;
  align-items: center;
  gap: 12px;
}

.header-row--top {
  justify-content: space-between;
}

.header-row--bottom {
  flex-wrap: wrap;
  gap: 8px;
}

.action-btn--icon {
  width: 36px;
  height: 36px;
  padding: 0;
  justify-content: center;
}

.action-btn--icon span {
  display: none;
}

.action-btn--icon.action-btn--primary {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: var(--color-on-primary);
}

.drawer-trigger {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 44px;
  padding: 0 16px;
  border-radius: var(--rounded-md, 8px);
  background: transparent;
  border: 1px solid var(--color-hairline);
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 500;
  color: var(--color-ink);
  cursor: pointer;
  min-width: 44px;
  transition: border-color 0.15s ease, color 0.15s ease;
}

.drawer-trigger:hover {
  border-color: var(--color-ink);
}

.drawer-trigger :deep(svg) {
  width: 18px;
  height: 18px;
}

.category-chip {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding: 0 14px;
  border-radius: 9999px;
  background: var(--color-surface-card);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 500;
}
  • Step 3: 提交
git add app/pages/index/index.vue
git -c commit.gpgsign=false commit -m "feat(index): three-row mobile header with drawer trigger"

Task 7: 首页 — 断点重写 + 列数阈值

Files:

  • Modify: app/pages/index/index.vue(script decideColumnCount 函数 + <style scoped>@media 块)

  • Step 1: 更新 decideColumnCount 阈值

定位:

function decideColumnCount(): number {
  const w = containerWidth()
  if (w < 520) return 1
  if (w < 760) return 2
  if (w < 1080) return 3
  if (w < 1440) return 4
  return 5
}

替换为:

function decideColumnCount(): number {
  const w = containerWidth()
  if (w < 520) return 1
  if (w < 900) return 2
  if (w < 1200) return 3
  if (w < 1600) return 4
  return 5
}
  • Step 2: 重写 @media 断点

定位 <style scoped> 末尾的 /* ── Responsive ── */ 块,把现有所有 @media 规则全部删除(含 max-width: 900pxmax-width: 640px),替换为:

/* ── Responsive (DESIGN.md breakpoints) ── */

@media (max-width: 767.98px) {
  .home-layout {
    grid-template-columns: 1fr;
  }

  .home-sidebar {
    display: none; /* rail 节点在移动端被 v-if 隐藏;drawer 由 BoDrawer 渲染到 body */
  }

  .home-main {
    padding: 16px 16px 32px;
  }

  .main-header {
    flex-direction: column;
    align-items: stretch;
    gap: 12px;
    padding-bottom: 16px;
    margin-bottom: 16px;
  }

  .header-title {
    font-size: clamp(22px, 7vw, 30px);
  }
}

@media (min-width: 768px) and (max-width: 1023.98px) {
  .home-layout {
    grid-template-columns: 240px 1fr;
  }

  .home-main {
    padding: 20px 16px 32px;
  }
}

备注:@media (min-width: 1024px)@media (min-width: 1440px) 维持现有默认样式无需新增。

  • Step 3: 提交
git add app/pages/index/index.vue
git -c commit.gpgsign=false commit -m "feat(index): align breakpoints with DESIGN.md"

Task 8: bolt-ui README 增加 Drawer 章节

Files:

  • Modify: packages/bolt-ui/README.md

  • Step 1: 追加章节

打开 packages/bolt-ui/README.md,在文件末尾追加

## Drawer 抽屉

从屏幕边缘滑出的面板,常用于移动端侧栏、设置面板、通知中心等场景。

### 基础用法

```vue
<BoDrawer v-model:open="open" side="left" width="80%">
  <div class="my-panel">面板内容</div>
</BoDrawer>

Props

名称 类型 默认值 说明
open boolean 是否打开,配合 v-model:open 双向绑定
side 'left' | 'right' 'left' 滑出方向
width string | number '80%' 面板宽度;数字视为 px
closeOnMask boolean true 点击遮罩是否关闭
closeOnEsc boolean true 按 Esc 是否关闭
showMask boolean true 是否显示遮罩
zIndex number 200 面板 z-index
transitionDuration number 280 过渡时长 (ms)
teleportTo string 'body' Teleport 目标选择器
ariaLabel string '抽屉' ARIA 标签

Emits

事件 参数 说明
update:open (value: boolean) v-model:open 监听

行为

  • 打开时锁定 body 滚动,关闭时恢复
  • 自动焦点管理:打开时聚焦面板内第一个可聚焦元素,关闭时返回触发器
  • 内置焦点陷阱(Tab / Shift+Tab 在面板内循环)
  • Teleport 到 body,避免父级 overflow: hidden / transform 影响
  • SSR 安全:服务端不渲染 Teleport,客户端 onMounted 后挂载

useMediaQuery

const isMobile = useMediaQuery('(max-width: 767.98px)')

响应式地跟踪媒体查询匹配状态。SSR 期间返回 false,客户端 mount 后由 matchMedia 真实驱动,自动清理监听。


- [ ] **Step 2: 提交**

```bash
git add packages/bolt-ui/README.md
git -c commit.gpgsign=false commit -m "docs(bolt-ui): document Drawer and useMediaQuery"

Task 9: 手工验收清单(dev server 启动 + 多视口回归)

Files:

  • Step 1: 启动 dev server
cd /home/dash/coding/nuxt-app
bun run dev

等待输出 Nuxt ... ready in ... ms 后浏览器打开 http://localhost:3000/

  • Step 2: 桌面端回归 (1440×900)

Chrome DevTools 设备模式关闭(默认),逐项检查:

  • 左侧栏在 280px 位置、内容不变

  • 顶部 header 只有 eyebrow + 标题 + 三个 action 按钮(一行)

  • 不渲染「≡ 分类」按钮与 category-chip

  • 切换分类、添加/删除分类、右键菜单全部正常

  • 滚动到底部正常加载更多

  • 浏览器 console 无报错

  • Step 3: 移动端 (iPhone SE 375×667)

设备模式选 iPhone SE,逐项检查:

  • 左侧栏不再以顶部横条形式出现

  • header 三行:eyebrow+动作 / 标题 / 「≡ 分类」+ 分类 chip

  • 点击「≡ 分类」→ 抽屉从左侧滑出

  • 抽屉内点击分类 → 主视图切换 + 抽屉自动关闭

  • 点击抽屉外遮罩 → 抽屉关闭

  • 按 Esc(外接键盘)→ 抽屉关闭

  • 抽屉打开时页面无法滚动

  • 抽屉关闭后焦点回到「≡ 分类」按钮

  • 关闭抽屉后页面恢复正常滚动

  • Step 4: 平板 (iPad Mini 768×1024 portrait / 1024×768 landscape)

设备模式选 iPad Mini,逐项检查:

  • portrait:rail 侧栏 240px + 双列瀑布

  • landscape:rail 侧栏 280px + 三列瀑布(桌面布局回归)

  • header 仍是一行(仅 rail 节点)

  • Step 5: 旋转测试

iPhone SE,从 portrait → landscape → portrait:

  • 旋转过程中布局平滑切换,无组件残留

  • console 无 "Hydration mismatch" 警告

  • Step 6: 提交(如有修复)

git add app
git -c commit.gpgsign=false commit -m "fix(index): address mobile/tablet smoke findings"

如果所有检查通过、无需修改,跳过此提交。


收尾

完成所有任务后:

  1. bun run build 在根目录执行构建,验证 bolt-ui 包无 TS 错误
  2. 浏览器开 http://localhost:3000/ 做最终肉眼检查
  3. 提交所有未追踪文件