Browse Source

feat(homepage): mobile adaptation with Drawer and responsive layout

Implement mobile-first responsive layout for the homepage per DESIGN.md.

bolt-ui:
- Add Drawer component (SSR-safe, keydown, theme styles)
- Add useMediaQuery composable
- Document both in bolt-ui README

index page:
- Sidebar switches between rail (desktop) and drawer (mobile) modes
- Three-row mobile header with drawer trigger
- Breakpoints aligned with DESIGN.md
- Mobile-first masonry column defaults (no flash on mobile refresh)

docs:
- Add homepage mobile adaptation design spec
- Add implementation plan and self-review notes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
as
npmrun 2 weeks ago
parent
commit
0d6338cb72
  1. 201
      app/components/index/LeftSidebar.vue
  2. 2
      app/pages/about/index.vue
  3. 228
      app/pages/index/index.vue
  4. 1074
      docs/superpowers/plans/2026-06-04-homepage-mobile-adaptation.md
  5. 350
      docs/superpowers/specs/2026-06-04-homepage-mobile-adaptation-design.md
  6. 83
      packages/bolt-ui/README.md
  7. 7
      packages/bolt-ui/components/Drawer/index.ts
  8. 156
      packages/bolt-ui/components/Drawer/src/Drawer.vue
  9. 1
      packages/bolt-ui/components/Drawer/style/css.ts
  10. 1
      packages/bolt-ui/components/Drawer/style/index.ts
  11. 3
      packages/bolt-ui/components/index.ts
  12. 1
      packages/bolt-ui/hooks/index.ts
  13. 31
      packages/bolt-ui/hooks/useMediaQuery/index.ts
  14. 7
      packages/bolt-ui/theme-chalk/src/components/drawer/_index.scss
  15. 3
      packages/bolt-ui/theme-chalk/src/components/drawer/_var.scss
  16. 1
      packages/bolt-ui/theme-chalk/src/components/drawer/anim/_var.scss
  17. 11
      packages/bolt-ui/theme-chalk/src/components/drawer/anim/mask-fade.scss
  18. 11
      packages/bolt-ui/theme-chalk/src/components/drawer/anim/panel-left-fade.scss
  19. 11
      packages/bolt-ui/theme-chalk/src/components/drawer/anim/panel-right-fade.scss
  20. 9
      packages/bolt-ui/theme-chalk/src/components/drawer/mask.scss
  21. 3
      packages/bolt-ui/theme-chalk/src/components/drawer/panel-left.scss
  22. 3
      packages/bolt-ui/theme-chalk/src/components/drawer/panel-right.scss
  23. 14
      packages/bolt-ui/theme-chalk/src/components/drawer/panel.scss
  24. 3
      packages/bolt-ui/theme-chalk/src/dialog.scss
  25. 1
      packages/bolt-ui/theme-chalk/src/drawer.scss

201
app/components/index/LeftSidebar.vue

@ -13,7 +13,15 @@ const props = withDefaults(defineProps<{
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' },
@ -31,6 +39,7 @@ const emit = defineEmits<{
deleteCategory: [id: string]
moveCategory: [id: string]
changeCover: [id: string]
'update:drawerOpen': [v: boolean]
}>()
const expandedIds = ref<Set<string>>(new Set())
@ -111,68 +120,142 @@ function onAddRoot() {
</script>
<template>
<aside class="left-sidebar">
<!-- Toolbar -->
<nav class="toolbar">
<button
v-for="tool in tools"
:key="tool.key"
type="button"
class="tool-item"
:class="{ active: activeToolKey === tool.key }"
:title="tool.label"
@click="onSelectTool(tool.key)"
>
<Icon :name="tool.icon" class="tool-icon" />
<span class="tool-label">{{ tool.label }}</span>
</button>
</nav>
<div class="divider" />
<!-- Category section header -->
<div class="cat-header">
<span class="cat-title">分类</span>
<button
type="button"
class="cat-add"
title="新建分类"
@click="onAddRoot"
>
<Icon name="lucide:plus" />
</button>
</div>
<!-- Tree -->
<div class="tree-scroll">
<div v-if="categories.length === 0" class="empty-tip">
<Icon name="lucide:folder-open" class="empty-icon" />
<span>还没有分类</span>
<button type="button" class="empty-btn" @click="onAddRoot">
新建第一个
<template v-if="mode === 'rail'">
<aside class="left-sidebar">
<!-- Toolbar -->
<nav class="toolbar">
<button
v-for="tool in tools"
:key="tool.key"
type="button"
class="tool-item"
:class="{ active: activeToolKey === tool.key }"
:title="tool.label"
@click="onSelectTool(tool.key)"
>
<Icon :name="tool.icon" class="tool-icon" />
<span class="tool-label">{{ tool.label }}</span>
</button>
</nav>
<div class="divider" />
<!-- Category section header -->
<div class="cat-header">
<span class="cat-title">分类</span>
<button
type="button"
class="cat-add"
title="新建分类"
@click="onAddRoot"
>
<Icon name="lucide:plus" />
</button>
</div>
<!-- Tree -->
<div class="tree-scroll">
<div v-if="categories.length === 0" class="empty-tip">
<Icon name="lucide:folder-open" class="empty-icon" />
<span>还没有分类</span>
<button type="button" class="empty-btn" @click="onAddRoot">
新建第一个
</button>
</div>
<IndexCategoryTreeNode
v-for="node in categories"
:key="node.id"
:node="node"
:active-id="activeCategoryId"
:expanded-ids="expandedIds"
@select="onSelectCategory"
@contextmenu="onTreeContextMenu"
@toggle-expand="toggleExpand"
/>
</div>
<IndexCategoryTreeNode
v-for="node in categories"
:key="node.id"
:node="node"
:active-id="activeCategoryId"
:expanded-ids="expandedIds"
@select="onSelectCategory"
@contextmenu="onTreeContextMenu"
@toggle-expand="toggleExpand"
<IndexContextMenu
:visible="ctxVisible"
:x="ctxX"
:y="ctxY"
:items="ctxItems"
@select="onCtxSelect"
@close="onCtxClose"
/>
</aside>
</template>
<BoDrawer
v-else
:open="drawerOpen"
:side="side"
:width="width"
:z-index="200"
@update:open="(v: boolean) => emit('update:drawerOpen', v)"
>
<aside class="left-sidebar">
<!-- Toolbar -->
<nav class="toolbar">
<button
v-for="tool in tools"
:key="tool.key"
type="button"
class="tool-item"
:class="{ active: activeToolKey === tool.key }"
:title="tool.label"
@click="onSelectTool(tool.key)"
>
<Icon :name="tool.icon" class="tool-icon" />
<span class="tool-label">{{ tool.label }}</span>
</button>
</nav>
<div class="divider" />
<!-- Category section header -->
<div class="cat-header">
<span class="cat-title">分类</span>
<button
type="button"
class="cat-add"
title="新建分类"
@click="onAddRoot"
>
<Icon name="lucide:plus" />
</button>
</div>
<!-- Tree -->
<div class="tree-scroll">
<div v-if="categories.length === 0" class="empty-tip">
<Icon name="lucide:folder-open" class="empty-icon" />
<span>还没有分类</span>
<button type="button" class="empty-btn" @click="onAddRoot">
新建第一个
</button>
</div>
<IndexCategoryTreeNode
v-for="node in categories"
:key="node.id"
:node="node"
:active-id="activeCategoryId"
:expanded-ids="expandedIds"
@select="onSelectCategory"
@contextmenu="onTreeContextMenu"
@toggle-expand="toggleExpand"
/>
</div>
<IndexContextMenu
:visible="ctxVisible"
:x="ctxX"
:y="ctxY"
:items="ctxItems"
@select="onCtxSelect"
@close="onCtxClose"
/>
</div>
<IndexContextMenu
:visible="ctxVisible"
:x="ctxX"
:y="ctxY"
:items="ctxItems"
@select="onCtxSelect"
@close="onCtxClose"
/>
</aside>
</aside>
</BoDrawer>
</template>
<style scoped>

2
app/pages/about/index.vue

@ -468,7 +468,7 @@ function onResetCount() {
<!-- 基础 Dialog -->
<BoDialog v-model:show="basicShow" @close="onBasicClose">
<div class="rounded-lg bg-white p-6 shadow-xl border border-[#e6dfd8] w-[420px]">
<div class="rounded-lg bg-white p-6 shadow-xl border border-[#e6dfd8]">
<h3 class="text-[20px] font-medium text-[#141413]">基础 Dialog</h3>
<p class="mt-3 text-[14px] text-[#3d3d3a] leading-[1.55]">
这是最基础的 Dialog 用法使用 <code class="px-1 bg-[#efe9de] rounded text-[#cc785c]">v-model:show</code> 绑定显示状态

228
app/pages/index/index.vue

@ -6,6 +6,9 @@ definePageMeta({
})
const { $toast } = useNuxtApp()
const isMobile = useMediaQuery('(max-width: 767.98px)')
const drawerOpen = ref(false)
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
interface CardItem {
id: number
@ -144,6 +147,7 @@ function onSelectTool(key: string) {
function onSelectCategory(id: string) {
activeCategoryId.value = id
if (drawerOpen.value) drawerOpen.value = false
}
function onAddCategory(parentId: string | null) {
@ -227,9 +231,9 @@ const hasMore = ref(true)
const loading = ref(false)
const initLoading = ref(true)
const columnCount = ref(3)
const columns = ref<CardItem[][]>([[], [], []])
const columnHeights = ref<number[]>([0, 0, 0])
const columnCount = ref(1)
const columns = ref<CardItem[][]>([[]])
const columnHeights = ref<number[]>([0])
function containerWidth(): number {
return mainRef.value?.clientWidth ?? 800
@ -271,12 +275,11 @@ function distributeAll() {
}
function decideColumnCount(): number {
if (isMobile.value) return 1
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
if (w < 900) return 2
if (w < 1200) return 3
return 4
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null
@ -323,6 +326,14 @@ watch(activeCategoryId, () => {
loadMore()
})
watch(isMobile, () => {
const n = decideColumnCount()
if (n !== columnCount.value) {
columnCount.value = n
distributeAll()
}
})
let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null
@ -341,6 +352,7 @@ watch(sentinel, (el) => {
onMounted(() => {
if (mainRef.value) {
columnCount.value = decideColumnCount()
distributeAll()
resizeObserver = new ResizeObserver(onContainerResize)
resizeObserver.observe(mainRef.value)
}
@ -357,10 +369,29 @@ onUnmounted(() => {
<template>
<div class="home-layout">
<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: boolean) => drawerOpen = v"
@select-tool="onSelectTool"
@select-category="onSelectCategory"
@add-category="onAddCategory"
@ -372,22 +403,58 @@ onUnmounted(() => {
<main ref="mainRef" class="home-main">
<header class="main-header">
<div class="header-left">
<span class="header-eyebrow">分类</span>
<!-- Desktop / tablet layout (>= 768px) -->
<template v-if="!isMobile">
<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>
</template>
<!-- Mobile layout (< 768px) -->
<template v-else>
<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>
<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>
<div 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>
</template>
</header>
<div class="masonry">
@ -631,42 +698,111 @@ onUnmounted(() => {
50% { opacity: 0.8; transform: scaleY(1); }
}
/* ── Responsive ── */
/* ── Responsive (DESIGN.md breakpoints) ── */
@media (max-width: 900px) {
@media (max-width: 767.98px) {
.home-layout {
grid-template-columns: 240px 1fr;
grid-template-columns: 1fr;
}
.home-sidebar {
display: none; /* rail 节点在移动端被 v-if 隐藏;drawer 由 BoDrawer 渲染到 body */
}
.home-main {
padding: 20px 16px 32px;
padding: 16px 16px 32px;
}
}
@media (max-width: 640px) {
.home-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
.main-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding-bottom: 16px;
margin-bottom: 16px;
}
.home-sidebar {
position: relative;
top: 0;
max-height: 260px;
height: auto;
border-right: none;
border-bottom: 1px solid var(--color-hairline);
.header-title {
font-size: clamp(22px, 7vw, 30px);
}
}
.main-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
@media (min-width: 768px) and (max-width: 1023.98px) {
.home-layout {
grid-template-columns: 240px 1fr;
}
.header-actions {
width: 100%;
justify-content: flex-start;
.home-main {
padding: 20px 16px 32px;
}
}
/* ── 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.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;
}
</style>

1074
docs/superpowers/plans/2026-06-04-homepage-mobile-adaptation.md

File diff suppressed because it is too large

350
docs/superpowers/specs/2026-06-04-homepage-mobile-adaptation-design.md

@ -0,0 +1,350 @@
# 首页移动端适配设计
## 概述
首页当前在 `<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
```ts
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
### 主题变量
```scss
: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
```ts
interface LeftSidebarProps {
// 原有
tools?: ToolItem[]
categories: CategoryNode[]
activeToolKey?: string
activeCategoryId?: string
// 新增
mode: 'rail' | 'drawer'
drawerOpen: boolean
width?: string = '300px'
side?: 'left' | 'right' = 'left'
}
```
### 新增 Emits
```ts
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]
}>()
```
### 模板分支
```vue
<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 面板内的右键事件天然兼容。
---
## 首页集成
### 响应式状态
```ts
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-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)` 关闭
### 状态联动
- `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)`,替换为:
```scss
@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`
```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 配合)
- 抽屉内容懒加载(首次打开才渲染分类树)

83
packages/bolt-ui/README.md

@ -0,0 +1,83 @@
# bolt-ui
A Nuxt 4 component library for the homepage and admin UI. Built on Vue 3.5 with TypeScript and SCSS.
## Auto-Registration
When used via the `bolt-ui/nuxt` module (already wired in `nuxt.config.ts`), components and composables under this package are auto-registered globally:
- Components at `components/<Name>/index.ts` are available as `<BoName>` (e.g. `components/Button/index.ts``<BoButton>`)
- Composables exporting `useXxx` functions under `hooks/`, `utils/`, or `locales/` are auto-imported (e.g. `hooks/useClickOutside/index.ts` exports `useClickOutside`)
- Theme styles are auto-injected on first use of each component
## Components
| Name | Description |
|------|-------------|
| `BoButton` | Standard button with type / size / loading / disabled variants |
| `BoConfigProvider` | Provides design tokens and locale to descendants |
| `BoContainer` | Centered max-width container with size variants |
| `BoDialog` | Modal dialog with Teleport + Mask + Transition |
| `BoDrawer` | Side-sliding panel (left/right) with mask, focus trap, and body scroll lock |
| `BoMask` | Full-screen mask overlay |
## 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` 后挂载
## Composables
### useMediaQuery
```ts
const isMobile = useMediaQuery('(max-width: 767.98px)')
```
响应式地跟踪媒体查询匹配状态。SSR 期间返回 `false`,客户端 mount 后由 `matchMedia` 真实驱动,自动清理监听。
### useClickOutside
```ts
useClickOutside(elementRef, (event) => {
// triggered when click is outside elementRef
}, { ignore: [someOtherRef] })
```
监听元素外部的点击事件,可选地忽略某些元素。SSR 期间无操作,客户端 mount 后挂载。

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

@ -0,0 +1,7 @@
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

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

@ -0,0 +1,156 @@
<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>
<div
v-show="isOpen"
ref="panelRef"
:class="panelClasses"
:style="panelStyle"
role="dialog"
aria-modal="true"
:aria-label="ariaLabel"
tabindex="-1"
@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) => {
if (typeof window === 'undefined') return
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 focusFirst() {
const root = panelRef.value
if (!root) return
const focusable = getFocusable(root)
if (focusable.length > 0) {
focusable[0]?.focus?.()
} else {
root.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>

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

@ -0,0 +1 @@
import 'bolt-ui/theme-chalk/src/drawer.scss'

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

@ -0,0 +1 @@
import 'bolt-ui/theme-chalk/src/drawer.scss'

3
packages/bolt-ui/components/index.ts

@ -2,4 +2,5 @@ export * from './Button'
export * from './ConfigProvider'
export * from './Container'
export * from './Dialog'
export * from './Mask'
export * from './Mask'
export * from './Drawer'

1
packages/bolt-ui/hooks/index.ts

@ -1 +1,2 @@
export * from './useClickOutside'
export * from './useMediaQuery'

31
packages/bolt-ui/hooks/useMediaQuery/index.ts

@ -0,0 +1,31 @@
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
}

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

@ -0,0 +1,7 @@
@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";

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

@ -0,0 +1,3 @@
$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;

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

@ -0,0 +1 @@
$drawer-time: 0.28s !default;

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

@ -0,0 +1,11 @@
@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;
}

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

@ -0,0 +1,11 @@
@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%);
}

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

@ -0,0 +1,11 @@
@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%);
}

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

@ -0,0 +1,9 @@
@use "./_var" as *;
.bo-drawer__mask {
position: fixed;
inset: 0;
background: $bo-drawer-mask-bg;
z-index: 199; // panel 1
backdrop-filter: blur(2px);
}

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

@ -0,0 +1,3 @@
.bo-drawer--left {
left: 0;
}

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

@ -0,0 +1,3 @@
.bo-drawer--right {
right: 0;
}

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

@ -0,0 +1,14 @@
@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;
}

3
packages/bolt-ui/theme-chalk/src/dialog.scss

@ -38,6 +38,9 @@
#{e('content')} {
margin: auto;
width: 30%;
@media (max-width: 768px) {
width: 90%;
}
// margin-top: 20vh;
}
}

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

@ -0,0 +1 @@
@use "components/drawer/_index.scss";
Loading…
Cancel
Save