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(scriptdecideColumnCount函数 +<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: 900px 和 max-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"
如果所有检查通过、无需修改,跳过此提交。
收尾
完成所有任务后:
bun run build在根目录执行构建,验证bolt-ui包无 TS 错误- 浏览器开
http://localhost:3000/做最终肉眼检查 - 提交所有未追踪文件