第十二章. 页面布局:构建核心 Layout
第十二章. 页面布局:构建核心 Layout
Prorise第十二章. 页面布局:构建核心 Layout
在第十一章中,项目拥有了导航能力和一套可维护的路由架构。然而,目前所有页面(如 WelcomePage, NotFoundPage)都是直接渲染,缺乏统一的视觉结构。
本章的任务是构建应用的 视觉骨架——Layout (布局) 组件。将实现两种核心布局:SimpleLayout(用于登录、注册等简单页面)和 DashboardLayout(用于后台主界面,包含头部、侧边栏和内容区域)。同时,将利用已有的 UI 组件基础
12.1. 布局策略:嵌套路由与 Layout 组件
在构建具体的布局组件之前,首先需要理解 React Router v7 是如何支持和推荐布局模式的。
12.1.1. React Router 的布局机制:嵌套路由与 <Outlet />
React Router v7(以及 v6)通过 嵌套路由 (Nested Routes) 和 <Outlet /> 组件,为实现布局提供了极其优雅和强大的原生支持。
回顾在 11.4.6 节中重构的 src/routes/index.tsx:
1 | // src/routes/index.tsx (部分) |
这里的 children 数组定义的就是 嵌套路由。其工作流程是:
- 当 URL 匹配到
/时,RouterProvider首先渲染rootRoute定义的Component,即<LazyMyApp />。 - 然后,
RouterProvider会继续检查 URL 是否匹配children中的某一项。 - 由于
LazyWelcomePage配置了index: true,当 URL 恰好是/时,它被匹配。 - 关键点:
<LazyWelcomePage />不会 替换掉<LazyMyApp />。相反,<LazyMyApp />内部必须包含一个<Outlet />组件。React Router 会将匹配到的子路由组件(这里是<LazyWelcomePage />)渲染到<Outlet />的位置。
在 11.2.5 节中,MyApp.tsx 的实现正是如此:
1 | // src/MyApp.tsx (部分) |
这种模式的 强大之处 在于:
- 层级化结构:布局组件(如
MyApp)可以定义共享的 UI 元素(页头、页脚、侧边栏、全局上下文),而子路由组件只负责渲染其特定的内容。 - 任意嵌套:布局可以任意嵌套。例如,
/dashboard路由可以渲染一个<DashboardLayout />,而/dashboard/users路由的组件则会被渲染到<DashboardLayout />内部的<Outlet />中。 - 代码复用:共享的布局逻辑只需编写一次。
- 关注点分离:布局组件关心“框架”,页面组件关心“内容”。
12.1.2. SimpleLayout 与 DashboardLayout 职责划分
基于嵌套路由机制,可以为 prorise-admin 设计不同场景下的布局组件:
SimpleLayout(简单布局)- 职责:提供一个极其简洁的页面框架,通常用于独立的功能页面或展示页面。在当前的项目阶段,一个完美的例子就是 全局 404 页面。它需要一个统一的背景和居中容器,但不需要复杂的头部或侧边栏。
- 典型结构:一个简单的容器,使内容(例如“页面未找到”的提示信息)能在屏幕中水平和垂直居中。
- 实现:将创建一个
src/layouts/simple/index.tsx组件,它内部包含<Outlet />。
DashboardLayout(仪表盘布局)- 职责:提供标准的后台管理界面布局,用于承载所有核心功能页面。在当前阶段,我们可以将 欢迎页面 (
WelcomePage) 作为第一个置于此布局下的页面。 - 典型结构:通常包含:
- 顶部导航栏 (Header):固定在页面顶部,包含 Logo、用户头像等全局操作。
- 侧边导航栏 (Sider/Nav):固定在左侧,包含多层级的菜单项。
- 主内容区域 (Main/Content):页面的核心工作区。
<Outlet />将位于此区域内。
- 实现:将创建
src/layouts/dashboard/index.tsx组件,并可能包含header.tsx,nav/*.tsx,main.tsx等子组件。
- 职责:提供标准的后台管理界面布局,用于承载所有核心功能页面。在当前阶段,我们可以将 欢迎页面 (
路由配置策略展望:
在本章,我们将把 NotFoundPage 和 WelcomePage 分别应用上这两种布局。
- 未来,当我们在 第十六章 构建认证模块时,像 登录页 这样的页面,也将复用
SimpleLayout。 - 未来,所有在
/dashboard路径下的业务页面,都将统一使用DashboardLayout作为其容器。
12.2. SimpleLayout 实现
在 12.1 节明确了布局策略后,现在开始构建第一个具体的布局组件:SimpleLayout。它的设计目标是为那些不需要复杂导航、内容通常居中展示的页面(例如未来的登录、注册页面)提供一个简洁、一致的视觉框架。
12.2.1. 组件结构规划
SimpleLayout 的核心需求是提供一个包含页眉(Header)和主内容区(Main Content)的垂直结构。主内容区需要能够将其中的子内容(即具体页面)在可用空间内水平和垂直居中。
基于此,可以规划出如下的 JSX 结构骨架:

1 | <div className="flex min-h-screen flex-col ..."> {/* 根容器:垂直布局,最小高度占满屏幕 */} |
这个结构清晰地划分了页眉和主内容区,并利用 Flexbox (flex-grow, items-center, justify-center) 实现了主内容区的自动填充和内部居中。
12.2.2. 实现 HeaderSimple 子组件
从上面的结构规划可以看出,SimpleLayout 依赖一个名为 HeaderSimple 的子组件。在构建 SimpleLayout 之前,需要先实现这个页眉组件。
HeaderSimple 的职责非常简单:在页面顶部提供一个横条,通常包含 Logo(指向首页)和可能的全局操作入口(例如主题设置)。
1. (编码) 创建文件:
根据 slash-admin 的结构约定,将布局相关的辅助组件放在 src/layouts/components/ 目录下。
1 | mkdir -p src/layouts/components |
2. (编码) 实现 HeaderSimple.tsx:
基于您提供的代码,实现如下:
文件路径: src/layouts/components/header-simple.tsx
1 | // 1. 导入依赖项 |
实现细节解析:
- 组件导入: 正确导入了已存在的
Logo组件。对于尚未实现的SettingButton,暂时保留导入和使用(或使用占位符<div />替代),这符合“先骨架后细节”的开发节奏。 - 语义化 HTML: 使用
<header>标签增强了页面的语义结构。 - Tailwind CSS: 通过一系列原子类 (
flex,h-16,items-center,justify-between,px-6) 精确、声明式地控制了页眉的布局和外观,无需编写任何额外的 CSS 文件。 - 组件组合:
HeaderSimple本身不包含复杂逻辑,它通过组合Logo和SettingButton来实现其功能。
12.2.3. (编码) 实现 SimpleLayout.tsx 主体
现在 HeaderSimple 已经就绪(至少骨架已定),可以完成 SimpleLayout.tsx 的实现。
1. (编码) 创建文件:
1 | mkdir -p src/layouts/simple |
2. (编码) 实现 SimpleLayout.tsx:
文件路径: src/layouts/simple/index.tsx
1 | import type React from "react"; |
完整实现解析:
- 布局结构: 完全遵循了 12.2.1 规划的 JSX 结构。
- 主题集成: 通过
bg-background和text-foreground类,布局的颜色与第九章建立的 CSS 桥接层和主题系统正确关联。 <Outlet />: 明确了子路由内容的渲染位置。- Flexbox 运用: 充分利用了 Flexbox 的能力(
flex-col,min-h-screen,flex-grow,items-center,justify-center)来实现自适应和居中布局。 - 可扩展性: 预留了添加 Footer 的位置。
12.2.4. (验证) 创建开发专用测试路由
为了在真实的应用路由环境中验证 SimpleLayout(及其 <Outlet />)的布局效果,我们不使用 Storybook 模拟,而是创建一个仅在开发环境下生效的 /dev 测试路由。
1. (编码) 创建可复用的 MockPage 组件
这个组件将作为所有布局测试的通用“子页面”内容。
1 | mkdir -p src/components/dev |
文件路径: src/components/dev/MockPage.tsx
1 | /** |
2. (编码) 创建 dev 路由模块
创建一个新的路由 “section” 文件,专门存放开发专用的路由。
1 | touch src/routes/sections/dev.tsx |
文件路径: src/routers/sections/dev.tsx
1 | import { lazy } from "react"; |
3. (编码) 接入主路由 index.ts
修改主路由文件,将 devRoutes 动态地合并到根路由的 children 中。
文件路径: src/router/index.ts
1 |
|
4. (验证) 运行并访问
现在,启动我们的开发服务器 (pnpm dev)。
在浏览器中访问 http://localhost:5173/dev (或我们的开发端口)。我们将看到 MockPage 卡片内容被 SimpleLayout 正确包裹(包含页眉)并在页面中央显示。
实现解析:通过 process.env.NODE_ENV === 'development' 条件判断,这个 /dev 路由只存在于开发模式中。在执行 pnpm build 进行生产打包时,该 if 块代码将被 tree-shaking 移除,devRoutes 将是一个空数组,确保了生产包的纯净性。
任务 12.2 已完成!SimpleLayout 及其依赖 HeaderSimple 已实现,并通过真实的应用内路由进行了验证。
12.3. src/ui 增强:TDD/CDD 构建 ScrollArea
在我们开始构/建 DashboardLayout(仪表盘布局)之前,我们必须先准备好它的一个关键依赖项。DashboardLayout 的侧边导航栏(nav)在内容(菜单项)过多时,必须能够优雅地滚动。
这就引入了一个在企业级项目中必须解决的经典问题:滚动条样式。
12.3.1. 架构决策:为何封装 ScrollArea
在第八章中,我们确立了 src/ui 的设计哲学:组合 radix-ui(无头组件库)和 tailwind-css(样式)。
我们不能直接在布局代码中依赖 radix-ui 的原始组件(如 ScrollAreaPrimitive),也不能依赖浏览器丑陋且不一致的原生滚动条。
我们必须在 src/ui 层对 ScrollArea 进行封装,这为我们带来三大核心价值:
- 视觉一致性:这是最首要的原因。macOS 默认会隐藏滚动条,而 Windows 始终显示。通过封装,我们将利用 Radix 的能力和 Tailwind 的样式,强制所有平台都显示一个与
Prorise-Admin主题(例如,消费我们已定义的bg-border变量)完全一致的、纤细的美观滚动条。 - API 封装:Radix Primitives 提供了极高粒度的原子组件(如
Root,Viewport,Scrollbar,Thumb)。在业务中直接使用它们是繁琐且易出错的。src/ui的职责就是将这些原子组件组合成一个简洁、易用的 API。 - 可维护性:这是一个关键的架构考量。通过在
src/components/ui/scrollArea这一个目录中实现所有逻辑,我们的整个项目都只依赖这个封装后的组件。如果未来我们需要更换滚动条的底层实现,我们只需要修改这一个地方。
12.3.2. (编码) 谋定而后动:组件文件结构与依赖
遵循第八章 Button 组件建立的规范,每一个 ui 组件都必须拥有自己的文件夹,并包含实现、变体、故事、测试和文档。
1. 创建组件目录
1 | mkdir -p src/components/ui/scrollArea |
2. 创建组件核心文件 (空文件)
1 | # 1. 组件实现 |
3. (编码) 安装依赖项
ScrollArea 组件依赖于 @radix-ui/react-scroll-area。
1 | pnpm add @radix-ui/react-scroll-area |
12.3.3. (CDD) Story 先行:定义 API“契约” (桩)
我们开发流程的第一步,不是打开 scroll-area.tsx,而是打开 scroll-area.stories.tsx。我们将在这里“设计”组件的 API。
文件路径: src/components/ui/scrollArea/scroll-area.stories.tsx
1 | // 遵循您提示的规范,正确导入 Meta 类型 |
CDD 阶段小结:我们已经定义了 ScrollArea 的 API 契约(orientation prop)。现在 Storybook 处于“预期中的失败” (RED) 状态,因为它无法导入 ./scroll-area。
12.3.4. (TDD) Test 驱动:第一个失败的测试 (RED)
下一步,打开 .test.tsx 文件,编写一个最基础的、失败的测试来驱动我们的编码。
文件路径: src/components/ui/scrollArea/scroll-area.test.tsx
1 | import { render, screen } from "@testing-library/react"; |
TDD 阶段小结:运行 pnpm test:unit,scroll-area.test.tsx 中的测试将失败 (RED),因为 scroll-area.tsx 尚未导出任何内容。我们的目标现在非常明确:编写最少的代码让这个测试变绿 (GREEN)。
12.3.5. (编码) 最小实现:让第一个测试通过 (GREEN)
1. 定义样式变体 (桩)
首先,我们打开 .variants.tsx 文件,定义组件所需的最小样式。
文件路径: src/components/ui/scrollArea/scroll-area.variants.tsx
1 | import { cva } from 'class-variance-authority'; |
2. 编写最小实现代码
现在我们打开 scroll-area.tsx,编写让测试通过的最小代码。
文件路径: src/components/ui/scrollArea/scroll-area.tsx
1 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; |
GREEN! 此时,再次运行 pnpm test:unit,第一个测试(“应该能正确渲染子元素”)将会通过。
12.3.6. (编码) 增量实现:添加默认滚动条
1. 更新样式变体
回到 scroll-area.variants.tsx,添加 Scrollbar 和 Thumb 的样式。
文件路径: src/components/ui/scrollArea/scroll-area.variants.tsx (追加)
1 | // ... (Root 和 Viewport 变体之后) |
2. 更新组件实现
回到 scroll-area.tsx,创建内部 ScrollBar 组件并默认使用它。
文件路径: src/components/ui/scrollArea/scroll-area.tsx (修改)
1 | import * as React from 'react'; |
12.3.7. (编码) 最终实现:实现 orientation 逻辑 (GREEN)
虽然我们很希望可以通过 TDD 来驱动,但是在 jsdom 环境下渲染一个滚动条太过于麻烦了,所以我们一次性也是最后一次修改 scroll-area.tsx,实现完整的 API 契约。
文件路径: src/components/ui/scrollArea/scroll-area.tsx (修改)
1 | // ... (imports 和 ScrollBar 组件保持不变) ... |
12.3.8. (CDD) 完善 Storybook 故事
我们的组件现在功能完备且经过测试。回到 scroll-area.stories.tsx,补充完整的 StoryObj 导出,让 Storybook 可视化地展示我们的成果。
文件路径: src/components/ui/scrollArea/scroll-area.stories.tsx (追加)
1 | // ... (meta 定义和 DemoContent 保持不变) ... |
CDD 验证:运行 pnpm storybook。现在 UI/ScrollArea 下的所有故事都已正确渲染,并且 Controls 面板可以正常工作。
12.3.9. (文档) MDX 叙事文档
最后一步,我们创建 .mdx 文件来为组件提供专业的使用指南,完成 TDD/CDD 闭环的最后一步。
文件路径: src/components/ui/scrollArea/scroll-area.mdx
1 | import { Meta, Story, Controls } from '@storybook/blocks'; |
TDD/CDD/DDD 闭环:ScrollArea 组件现已完成。我们严格遵循了第八章的规范:
- CDD:在
scroll-area.stories.tsx中设计了 API 和视觉状态 (桩)。 - TDD:在
scroll-area.test.tsx中编写了增量的测试用例。 - Code:在
scroll-area.variants.tsx中分离了样式,并在scroll-area.tsx中逐步实现了功能,使 Story 和 Test 全部通过 (红-绿-重构)。 - Docs:在
scroll-area.mdx中编写了专业的叙事文档。
DashboardLayout 的关键依赖项已准备就绪。
12.4. DashboardLayout 骨架构建
在 12.3 节中,我们成功地 TDD/CDD 构建了 ScrollArea 组件。这是一个至关重要的 src/ui 组件,它确保了我们应用在所有平台上都能拥有一致的、符合主题的滚动条体验。
现在,ScrollArea 已经准备就绪,我们将开始构建它在 Prorise-Admin 中的第一个“消费者”:DashboardLayout (仪表盘布局)。
这是我们应用中 承载所有核心功能页面的视觉骨架。几乎所有的业务页面(如工作台、用户管理、系统设置等)都将被渲染在这个布局的 <Outlet /> 之中。因此,这个布局的稳健性、可维护性和可扩展性,对整个项目的质量起着决定性的作用。
12.4.1. 架构决策:CSS Grid 与组件拆分
在开始编写代码之前,我们必须先确定布局的实现策略。我们的目标是构建一个经典的“左侧固定导航 + 右侧头部/内容区”的 PC 端布局。
1. 布局技术选型:为什么选择 CSS Grid?
在 2025 年,实现这种布局的最佳实践是使用 CSS Grid,而非传统的 position: fixed 方案。
传统方案 (Fixed + Padding):
- 做法:侧边栏
position: fixed,主内容区设置一个动态的padding-left(例如240px)。 - 缺点:这是一种“侵入式”布局。侧边栏脱离了文档流,主内容区需要“感知”到侧边栏的宽度,并通过
padding为其“让位”。当侧边栏宽度需要变化(例如折叠成 “mini” 模式)时,主内容区必须通过 JavaScript 或 CSS 变量来 被动地 响应这个变化。这种布局耦合度高,且容易在复杂的z-index堆叠下产生不可预见的 bug。
- 做法:侧边栏
Prorise-Admin方案 (CSS Grid):- 做法:我们将
DashboardLayout的根节点 (<div>) 定义为一个两列网格 - (例如
display: grid; grid-template-columns: 240px 1fr;)。 - 优点:
- 声明式与解耦:布局结构在 父级(根节点)统一定义。侧边栏 (
NavVertical) 和主区域 (MainArea) 只是这个网格的“填充物”,它们 彼此之间互不感知,实现了完美的关注点分离。 - 稳健性:所有组件都在正常的文档流中。我们不再需要处理
fixed定位带来的z-index堆叠上下文问题。 - 易于维护:当侧边栏需要从
240px变为64px(mini 模式) 时,我们 只需要改变父级 Grid 的grid-template-columns定义 即可。主内容区会 自动、原生 地填充剩余空间,无需任何padding计算或 JavaScript 干预。
- 声明式与解耦:布局结构在 父级(根节点)统一定义。侧边栏 (
- 做法:我们将
1 | # 创建核心组件目录 |
预创建成功的 layouts 目录树
执行上述命令后,您的 src/layouts/ 目录结构将如下所示:
1 | └── 📂 layouts/ |
12.4.2. (编码) 任务 1 —— 定义 CSS 布局变量
在 12.4.1 节中,我们确定了使用 CSS Grid 作为 Prorise-Admin 的布局基石。Grid 布局的核心在于 grid-template-columns 属性,它需要一个具体的宽度值来定义侧边栏(例如 280px)。
架构思考:规避“魔法数字”
在 Prorise-Admin 的开发中,我们必须 严格规避“魔法数字”。
“魔法数字”是指在代码中未经解释、多处重复的硬编码值(例如 280px)。
我们 绝对不能 在 Tailwind 类名中硬编码这个宽度,例如 grid-cols-[280px_1fr]。为什么?
- 职责不一:布局组装器(
index.tsx)需要这个宽度来定义 Grid,而侧边栏(nav-vertical.tsx)也需要这个宽度来设置自己的width。这是一个 共享状态。 - 维护灾难:如果这个“魔法数字”被硬编码在多个文件中,未来任何调整(例如从
280px改为260px)都将是一场灾难。开发者必须去“猜”并“查找”所有使用到这个值的地方,这极易导致 Bug(例如 Grid 变了,但侧边栏的宽度没变)。
解决方案:“唯一事实来源” (SSOT)
2025 年的最佳实践是使用 CSS 自定义属性(CSS 变量) 来作为这个“唯一事实来源”。我们将全局布局常量统一定义在我们的根样式表中。
1. (编码) 打开 src/index.css
让我们打开在第四章创建的 src/index.css 文件。找到 @layer base 规则,在 :root 选择器中,我们将添加新的布局变量。
文件路径: src/index.css (修改)
1 | /* ======================================== |
思考:通过在 :root 中定义,--layout-nav-vertical-width 和 --layout-header-height 现在成为了全局可用的“常量”。任何组件、任何 CSS 文件都可以访问它们,但它们只在 index.css 这 一个地方 被定义。
2. (配置) 让 Tailwind 识别新变量
虽然我们可以通过 h-[var(--layout-header-height)] 这样的方式在 Tailwind 中使用这些变量,但这既不优雅也不易读。
更好的做法是在 tailwind.config.ts 中“注册”它们,让它们成为 Tailwind “主题”的一部分,从而拥有更具语义的类名。
文件路径: tailwind.config.ts (修改)
1 | // ... (imports) ... |
实现解析:我们已经完成了 DashboardLayout 的“奠基”工作。我们创建了两个 CSS 变量作为“唯一事实来源”,并在 Tailwind 中注册了它们。
现在,我们可以在任何组件中通过 w-layout-nav-vertical (280px) 和 h-layout-header (64px) 来消费这些值,确保了布局尺寸的全局一致性和易维护性。如果未来我们需要将侧边栏宽度调整为 260px,我们 只需要修改 src/index.css 中的一行代码,整个应用的所有相关组件都会自动更新。
这就是企业级项目中所追求的 健壮性 与 可维护性。
12.4.3. (编码) 任务 2:创建 NavVertical 侧边栏
在 12.4.2 节中,我们在 src/index.css 中定义了一系列 CSS 布局变量。然而,tailwind.config.ts 文件尚未完全同步,无法消费所有这些新变量。
我们的第一个动作是 更新 Tailwind 配置,使其能够识别并转换我们在 src/index.css 中定义的所有新变量,为 NavVertical 组件及后续组件的开发做好准备。
1. (配置) 同步 tailwind.config.ts
打开 tailwind.config.ts 文件。我们将扩展 theme.extend.height 和 theme.extend.width 属性,使其与 src/index.css 中的 :root 变量 完全一致。
文件路径: tailwind.config.ts (修改)
1 | // ... (imports) ... |
实现解析:配置现已同步。我们可以在代码中使用 w-layout-nav 来代表 260px,使用 h-layout-header 来代表 64px。这种抽象级别对于编写可维护的布局至关重要。
2. (编码) 创建 NavVertical 组件
现在,我们开始构建 DashboardLayout 的第一个子组件:NavVertical。这是我们 Grid 布局中的 第 1 列。
职责分析:NavVertical 的职责是作为一个 固定的、全屏高的、有固定宽度的 视觉容器。它内部必须被划分为两个区域:
- Logo 区(头部):位于顶部,用于放置
Logo组件。它的高度必须 严格等于--layout-header-height(64px),这样它才能与 Grid 第 2 列中的Header组件在视觉上完美对齐。 - 菜单区(内容):占据 所有剩余的垂直空间。这个区域必须是可滚动的,因此它将成为我们
ScrollArea组件(12.3 节构建)的第一个“真正消费者”。

文件路径: src/layouts/dashboard/nav/nav-vertical.tsx (新创建)
首先,我们创建文件并添加必要的导入:
1 | // 1. 导入我们在第 10 章创建的 Logo 组件 |
接下来,我们定义组件的骨架。我们将使用 <nav> 语义化标签作为根元素,并为其应用实现核心布局的 Tailwind 类。
1 | /** |
现在,我们在 <nav> 内部创建 Logo 区。这个区域的高度必须严格等于 h-layout-header (64px)。
1 | // ... (imports) |
最后,我们构建 菜单区。这是 NavVertical 组件中 最具技术性的部分。
实现思路:我们需要让菜单区“填满 NavVertical 减去 Logo 区 (64px) 后的所有剩余空间”,并且“在该空间内部署滚动条”。
- 我们将创建一个
<div>容器,并使用flex-1类。这个类会告诉浏览器:“请计算Logo 区的高度(64px),然后将h-screen(屏幕高度)减去 64px,把所有剩余的空间都分配给这个<div>。” - 此时,这个
flex-1的<div>就有了一个 确定的、动态计算的高度(例如 1080px - 64px = 1016px)。 - 如果这个
<div>内部的内容(即菜单项)超过了 1016px,内容会“溢出”。我们必须添加overflow-hidden。这个类会告诉<div>:“请严格遵守你的 1016px 高度,不要被你的子内容撑开,并把溢出的部分隐藏掉。” - 只有当父元素(
flex-1 div)满足了“有确定高度”和“隐藏溢出内容”这两个条件时,ScrollArea组件才能正确地接管滚动条,实现内部滚动。
让我们来实现它:
1 | // 1. 导入我们在第 10 章创建的 Logo 组件 |
DashboardLayout 的第一个子组件 NavVertical 现已构建完成。它正确地消费了我们的全局 CSS 变量,并为未来的菜单项提供了一个结构稳健、可安全滚动的容器。
12.4.4. (编码) 任务 3:创建 Header 顶部导航栏
NavVertical 组件现已完成,它构成了我们 Grid 布局的第 1 列。接下来,我们开始构建 Grid 布局 第 2 列 中的 第一个元素:Header 顶部导航栏。
职责分析:Header 组件的核心职责是提供一个固定在主内容区顶部的水平栏,用于承载全局操作(如用户菜单、通知、设置)和上下文信息(如面包屑导航)。
- 布局位置:它在 Grid 第 2 列中,且必须“粘”在视口的顶部(
position: sticky)。 - 视觉对齐:其高度必须 严格等于
h-layout-header(64px),以确保与NavVertical的 Logo 区域在视觉上完美水平对齐。 - 视觉效果:为提升现代感,我们将实现“毛玻璃”效果(
backdrop-blur),使Header在主内容区滚动到其下方时,呈现半透明的模糊背景。 - 内部结构:它将使用 Flexbox 布局,划分为左、右两个插槽,为未来添加面包屑和用户菜单做好准备。
1. (编码) 创建 src/layouts/dashboard/header.tsx
我们在 12.4.1 节中已经通过 touch 命令创建了此文件。现在我们开始编写其内容。
首先,添加必要的 React 导入并定义组件函数。
文件路径: src/layouts/dashboard/header.tsx (新创建)
1 | import * as React from 'react'; |
2. (编码) 应用核心布局与样式
接下来,我们将为 <header> 元素添加 Tailwind 类名,以实现上述的“布局位置”和“视觉对齐”职责。
文件路径: src/layouts/dashboard/header.tsx (修改)
1 | export default function Header() { |
3. (编码) 实现毛玻璃 (Glassmorphism) 效果
要实现 backdrop-blur(毛玻璃)效果,必须满足两个条件:
- 元素本身必须是 半透明 的(例如
bg-background/80)。如果背景是 100% 不透明的,就无法“透”过它去模糊背后的内容。 - 应用
backdrop-blur-sm(或md,lg) 工具类。
文件路径: src/layouts/dashboard/header.tsx (修改)
1 | // ... (imports) |
4. (编码) 完善内部 Flexbox 布局
最后,我们完成 Header 内部的布局。我们将使用 Flexbox 将“左插槽”和“右插槽”推向容器的两端。
文件路径: src/layouts/dashboard/header.tsx (修改)
1 | // ... (imports) |
Header 组件现已构建完成。它具备了正确的高度、sticky 定位和毛玻璃效果,并为未来的功能扩展预留了清晰的插槽。
12.4.5. (编码) 任务 4:创建 MainArea 内容区
NavVertical (Grid 第 1 列) 和 Header (Grid 第 2 列的第 1 个元素) 已经构建完成。现在,我们开始构建 DashboardLayout 骨架的最后一块拼图:MainArea。
职责分析:MainArea 是 Grid 布局 第 2 列 中的 第 2 个元素。它的核心职责是:
- 布局定位:它必须位于
Header之下。 - 空间填充:它必须自动“填满”
Header之外的所有剩余垂直空间。 - 滚动处理:它必须是页面内容的 主要滚动容器。当页面内容溢出时,滚动条应出现在此元素上,而
Header保持固定在顶部。 - 内容渲染:它必须使用
<Outlet />来渲染当前匹配的子路由,并为React.lazy()加载的组件提供<Suspense>回退。
1. (编码) 创建 src/layouts/dashboard/main-area.tsx
我们在 12.4.1 节中已经创建了此文件。现在,我们来编写其内容。首先,导入所有必需的依赖项:Suspense (用于代码分割)、Outlet (用于路由渲染) 以及 RouteLoading (我们的加载占位符)。
文件路径: src/layouts/dashboard/main-area.tsx (新创建)
1 | import { Suspense } from 'react'; |
2. (编码) 定义组件骨架与路由出口
接下来,我们定义 MainArea 组件,使用语义化的 <main> 标签,并在内部设置好 Suspense 和 Outlet。
1 | import { Suspense } from 'react'; |
3. (编码) 应用核心布局与滚动类
这是本节最关键的一步。我们将为 <main> 标签添加 Tailwind 类,以实现上述的“空间填充”和“滚动处理”职责。
实现思路:在 12.4.6 节中,Header 和 MainArea 将被包裹在一个父级 div(Grid 第 2 列)中,该 div 会被设置为 flex flex-col。
例如:
1 | <div className="col-span-1 flex flex-col"> |
Header组件已经拥有shrink-0类,意味着它不会被压缩,并保持其h-layout-header(64px) 的高度。- 为了让
MainArea填满所有剩余空间,我们必须为其添加flex-1(等同于flex-grow: 1) 类。
文件路径: src/layouts/dashboard/main-area.tsx (修改)
1 | // ... (imports) |
MainArea 组件现已构建完成。它正确地实现了内容区的空间填充和滚动逻辑,并为所有子路由提供了渲染出口。
至此,NavVertical、Header 和 MainArea 三个核心子组件都已准备就绪。最后一步是将它们在 DashboardLayout 的主 index.tsx 文件中组装起来。
12.4.6. (编码) 任务 5:组装 DashboardLayout 主文件
NavVertical、Header 和 MainArea 三个核心子组件都已准备就绪。现在,我们进入本章的最后一步:在 src/layouts/dashboard/index.tsx 文件中,将它们组装成一个完整的、由 CSS Grid 驱动的布局。
职责分析:index.tsx 是布局的“组装器”。它的职责是:
- 实现 12.4.1 节中规划的 CSS Grid 布局(
grid-cols-[var(--layout-nav-width)_1fr])。 - 将
<NavVertical />放入 Grid 第 1 列。 - 创建一个“主工作区”容器 (
div) 并将其放入 Grid 第 2 列。 - 这个“主工作区”容器将是 页面滚动容器(
overflow-y-auto),并使用flex flex-col来垂直堆叠Header和MainArea。
1. (编码) 导入子组件
打开我们在 12.4.1 节创建的主布局文件,并导入我们刚刚构建的三个子组件。
文件路径: src/layouts/dashboard/index.tsx (新创建)
1 | import Header from "./header"; |
2. (编码) 实现 CSS Grid 布局
接下来,我们定义 DashboardLayout 组件,并应用核心的 Grid 布局类。
1 | import Header from './header'; |
3. (编码) 组装主工作区 (滚动容器)
这是组装阶段最关键的一步。Grid 的第 2 列必须包含 Header 和 MainArea。为了让 Header 的 sticky 定位生效,Grid 第 2 列本身必须是滚动容器。
实现思路:
- 我们创建一个
div作为 Grid 第 2 列的直接子元素。 - 为此
div添加flex flex-col,使其内部的Header和MainArea垂直堆叠。 - 为此
div添加overflow-y-auto,使其成为主滚动容器。 <Header />(带有sticky top-0) 将“粘”在此div的顶部。<MainArea />(带有flex-1) 将自动填充此div中除Header外的所有剩余空间。
1 | // ... (imports) |
12.4.7. (重构) 任务 6:修复 Loading 组件的作用域问题
在完成 DashboardLayout 的组装后,我们需要处理一个关键的架构问题:Suspense 的作用域与 Loading 组件的显示范围。
问题分析:
在我们的初始实现中,存在两个设计缺陷:
- Suspense 重复定义:在
MyApp.tsx(根布局) 和MainArea.tsx(内容区) 中都定义了Suspense,这会导致 loading 状态的管理混乱。 - Loading 样式错误:
RouteLoading组件使用了h-screen w-screen,这会使 loading 指示器覆盖整个屏幕,包括侧边栏和 Header,而不是仅在主内容区显示。
架构决策:Loading 应该在哪里显示?
在企业级应用中,路由切换时的 loading 状态应该 只在内容区域显示,而不应该覆盖全局导航和头部。这样做的好处是:
- 用户体验:用户始终能看到导航栏,知道自己在应用的哪个位置。
- 交互一致性:即使在 loading 状态下,用户仍可以点击导航菜单切换到其他页面。
- 视觉稳定性:避免整个 UI 的闪烁,只有内容区在加载,减少视觉干扰。
因此,我们的 唯一事实来源 (SSOT) 原则要求:
Suspense只应该在MainArea中定义(内容区的边界)。RouteLoading必须适配其父容器的大小(即MainArea),而非整个视口。
1. (重构) 修复 RouteLoading 组件
首先,我们需要将 RouteLoading 从 “全屏模式” 改为 “容器适配模式”。
文件路径: src/components/loading/route-loading.tsx (修改)
1 | import { Spin } from "antd"; |
实现解析:
- 之前 (
h-screen w-screen):loading 会占据整个视口(100vh × 100vw),覆盖侧边栏和 Header。 - 现在 (
h-full w-full):loading 会填充其父元素(MainArea)的 100% 高度和宽度。由于MainArea使用flex-1占据除 Header 外的所有剩余空间,loading 指示器将精确地显示在内容区域中。
2. (重构) 移除 MyApp.tsx 中的冗余 Suspense
接下来,我们需要从根布局中移除 Suspense,将其职责完全下放到 MainArea。
文件路径: src/MyApp.tsx (修改)
1 | import { Outlet } from "react-router-dom"; |
实现解析:
- 之前:
MyApp包含Suspense,这会导致懒加载组件(如DashboardLayout)在加载时触发全屏 loading。 - 现在:
MyApp只负责提供全局上下文(主题、UI 库配置),不处理 loading 状态。所有 loading 逻辑都在MainArea中统一管理。
3. (验证) MainArea 的 Suspense 保持不变
我们已经在 12.4.5 节中正确配置了 MainArea。让我们再次确认其实现:
文件路径: src/layouts/dashboard/main-area.tsx (无需修改,仅作确认)
1 | import { Suspense } from 'react'; |
架构验证:
现在我们的 loading 架构符合以下原则:
1 | MyApp (ThemeProvider, 无 Suspense) |
至此,我们的 loading 组件已经符合企业级应用的最佳实践:作用域清晰、视觉稳定、用户体验优先。
12.5. 路由集成:应用布局组件
DashboardLayout 的骨架和 loading 机制都已就绪,但目前它还没有被路由系统使用。在这个任务中,我们将把 DashboardLayout 正确地整合到路由配置中。
问题分析:
在我们的初始实现中,存在一个路由架构问题:
dashboardRoutes数组为空,导致所有页面都没有使用DashboardLayout。mainRoutes中直接定义了根路径的index路由,与dashboardRoutes职责重叠。
架构决策:路由的层级与职责划分
在企业级应用中,路由应该按照 布局边界 进行层级划分:
- 根路径 (
/):应该使用DashboardLayout,因为这是应用的主要功能区。 - 业务页面:所有需要侧边栏和头部的页面都应该作为
DashboardLayout的children。 - 独立页面:只有不需要布局的页面(如登录页、404)才应该在顶层路由中定义。
1. (配置) 更新 dashboardRoutes 配置
首先,我们需要将根路径路由移到 dashboardRoutes 中,并使用 DashboardLayout 作为其布局。
文件路径: src/routes/sections/dashboard.tsx (修改)
1 | import { lazy } from "react"; |
实现解析:
- 路由结构:我们创建了一个根路径路由 (
path: "/"),它使用LazyDashboardLayout作为布局组件。 - index 路由:
index: true表示这个路由会匹配父路径(即/),并渲染LazyWelcomePage。 - 代码分割:所有组件都使用
lazy()动态导入,确保初始加载时只下载必要的代码。 - 可扩展性:
children数组为未来添加更多页面(如/dashboard、/users)预留了清晰的位置。
2. (配置) 更新 mainRoutes 配置
接下来,我们需要从 mainRoutes 中移除根路径的 index 路由,因为它现在由 dashboardRoutes 负责。
文件路径: src/routes/sections/main.tsx (修改)
1 | import { lazy } from "react"; |
实现解析:
- 职责聚焦:
mainRoutes现在只负责 404 路由和其他独立页面(如未来的登录页)。 - 路由优先级:由于在
src/routes/index.tsx中,dashboardRoutes会在mainRoutes之前被合并,根路径/会优先匹配到dashboardRoutes中的index路由。
3. (验证) 确认路由合并逻辑
让我们确认 src/routes/index.tsx 中的路由合并顺序是否正确。
文件路径: src/routes/index.tsx (无需修改,仅作确认)
1 | // ... (imports) |
架构验证:
现在我们的路由结构如下:
1 | MyApp (/) |
路由匹配流程:
- 用户访问
http://localhost:5173/ - React Router 遍历
children数组 - 找到
dashboardRoutes中的path: "/"路由 - 渲染
DashboardLayout - 由于子路由中有
index: true,继续渲染WelcomePage WelcomePage在MainArea的<Outlet />中显示
测试验证:
现在访问以下路径应该得到预期结果:
http://localhost:5173/→ 显示DashboardLayout+WelcomePagehttp://localhost:5173/dashboard→ 显示 404(因为我们还没有定义这个路径)http://localhost:5173/any-invalid-path→ 显示 404
如果你想让 /dashboard 也显示相同的内容,可以在 dashboardRoutes 的 children 中添加:
1 | { |
至此,我们的 DashboardLayout 已经完全整合到路由系统中。它具备了以下企业级特性:
- 架构清晰:使用 CSS Grid 实现解耦的布局系统
- 性能优化:通过
React.lazy实现代码分割 - 用户体验:loading 状态只在内容区显示,不影响全局导航
- 可维护性:所有布局尺寸使用 CSS 变量,修改一处即可全局生效
- 可扩展性:为未来添加更多页面和功能预留了清晰的扩展点
在下一章中,我们将开始构建导航菜单系统,让 NavVertical 中的"菜单区"真正发挥作用。
12.6. 本章小结与代码入库
在本章中,项目完成了从"能导航"到"有结构"的关键跨越。通过系统性地构建两套核心布局组件和一个关键 UI 组件,为应用建立了稳固的视觉骨架和交互基础。
核心进展回顾:
布局策略确立 (12.1):深入理解了 React Router v7 的嵌套路由机制与
<Outlet />工作原理,明确了SimpleLayout与DashboardLayout的职责边界和应用场景,为后续所有页面的布局模式奠定了理论基础。SimpleLayout 实现 (12.2):构建了第一个生产级布局组件,实现了
HeaderSimple子组件,并通过开发专用的/dev测试路由验证了布局效果。这个布局为登录、注册等独立页面提供了简洁一致的视觉框架。ScrollArea 组件封装 (12.3):严格遵循 TDD/CDD 工作流,完成了项目中第一个完整的
src/ui组件构建周期。通过封装@radix-ui/react-scroll-area,实现了跨平台一致的、与主题系统深度集成的滚动条体验,为DashboardLayout的侧边导航做好了准备。DashboardLayout 骨架 (12.4):这是本章的核心成果。我们:
- 确立了使用 CSS Grid 而非传统
fixed定位的现代化布局方案 - 在
index.css中定义了全局布局 CSS 变量(SSOT 原则) - 构建了
NavVertical(侧边栏)、Header(顶部导航)、MainArea(主内容区)三个子组件 - 在
index.tsx中完成了 Grid 布局的组装,实现了解耦的、易维护的两列布局 - 修复了
RouteLoading的作用域问题,确保 loading 状态只在主内容区显示
- 确立了使用 CSS Grid 而非传统
路由系统集成 (12.5):重构了
dashboardRoutes和mainRoutes配置,将根路径/正确地应用了DashboardLayout,确保了布局边界与路由层级的一致性。现在访问/可以看到完整的仪表盘布局效果。
架构里程碑:
通过本章的工作,Prorise-Admin 现在拥有了:
- ✅ 两套生产级布局:
SimpleLayout和DashboardLayout,职责清晰 - ✅ 一个可复用的 UI 组件:
ScrollArea,完整的 TDD/CDD/Docs 闭环 - ✅ 企业级的 CSS 架构:布局变量集中管理,主题系统深度集成
- ✅ 现代化的布局技术:CSS Grid + Flexbox,声明式、高性能
- ✅ 优化的用户体验:Loading 作用域精确控制,Header 毛玻璃效果
这些基础设施将支撑后续所有业务页面的开发,为用户提供一致、流畅的操作体验。
代码入库
将本章的所有成果提交到版本控制系统。
1. 检查代码状态:
1 | git status |
2. 暂存所有变更:
1 | git add . |
3. 执行提交:
1 | git commit -m "feat(layouts): implement SimpleLayout & DashboardLayout with CSS Grid architecture" |
第十二章已圆满完成! 项目的视觉骨架已经建立,为后续的功能开发(导航菜单、用户管理、权限系统等)提供了坚实的基础。













