AppShell 提炼自我的一个企业监控平台项目的整体布局,去掉了接口请求、权限过滤和登录逻辑,只留下布局与交互本身:
- 固定顶栏:左侧品牌区宽度与导航对齐,右侧依次是
headerActions、亮暗模式切换、用户菜单;中间是自动生成的面包屑。 - 一级导航:按分组排列,当前页高亮;可折叠为图标栏(名称出现在 Tooltip 里),折叠状态记在
localStorage,刷新后首帧就是上次的状态,不会闪烁。 - 二级工作区侧栏:一级导航里的「模块」被选中时,右侧多出一栏,列出该模块下的页面。
- 独立滚动的主区域:顶栏和侧栏固定,只有
main滚动,切换页面自动回到顶部。 - 右侧
aside插槽:与main并排,高度占满顶栏以下的区域,用来放 AI 助手面板等。 - 窄屏适配:宽度小于 768px 时,一级导航变成左侧 Sheet 抽屉,二级导航变成顶栏下方的横向标签条。
- 演示页直接用站内自研的数据展示组件拼成:
- 颜色全部来自主题变量,切换颜色主题和亮暗模式后自动跟随,预览里可以直接试。
预览里没有真实路由,所以演示用 activeHref + onNavigate 在本地模拟导航;接入 Next.js 时这两个属性都不用传,组件会用 usePathname() 高亮当前页。
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/app-shell-01.json会安装 shadcn 的 button、avatar、breadcrumb、dropdown-menu、popover、sheet、tooltip,本站的 统计卡片、迷你图、图表卡片、数据表格 与 分段选择(用于概览、列表与范围切换),npm 包 lucide-react、next-themes,以及 hooks/use-controllable-state.ts。如果项目里已有同名文件,CLI 会询问是否覆盖。
亮暗模式按钮依赖项目里已有的
next-themesThemeProvider;不需要的话给AppShell传themeToggle={false}。
文件结构
安装后文件落在这些位置(项目使用 src/ 目录时会自动加上 src/ 前缀)。入口是 app-shell.tsx:
app/(app)/
├── layout.tsx # 控制台布局:把导航数据交给 AppShell
└── dashboard/
├── page.tsx # 示例页面:概览仪表盘
└── [...slug]/page.tsx # 导航中其余页面的占位:通用列表页 + 该页数据摘要
hooks/use-controllable-state.ts # 受控 / 非受控状态 hook
components/app-shell/
├── app-shell.tsx # ★ 入口:组合顶栏、导航、主区域、aside 插槽
├── app-shell-script.tsx # 首帧脚本:水合前恢复侧栏折叠状态
├── app-header.tsx # 固定顶栏:品牌、折叠按钮、面包屑、右侧操作区
├── primary-nav.tsx # 一级导航(支持折叠为图标栏)
├── workspace-sidebar.tsx # 二级工作区侧栏 + 窄屏横向标签条
├── nav-config.ts # ★ 导航数据,集中配置
├── nav-types.ts # 导航类型与 resolveNav(根据地址找到当前位置)
├── config.ts # ★ 默认文案、文案上下文、一级导航宽度与过渡类名、存储键
├── nav-link.tsx # 对 next/link 的封装,统一处理点击行为
├── page-header.tsx # PageHeader / PageTabs / PageContent / PageFrame
├── header-status.tsx # 顶栏服务状态弹层(数据由外部传入)
├── user-menu.tsx # 头像 + 下拉菜单
├── theme-toggle.tsx # 亮暗模式切换
├── use-stored-flag.ts # 持久化到 localStorage 的布尔状态
├── dashboard-overview.tsx # 演示:概览仪表盘(统计卡片 + 趋势图 + 图表区 + 事件表)
├── overview-charts.tsx # 演示:概览的错误预算、状态码分布、最慢接口三张图表卡片
├── distribution-card.tsx # 演示:环形图 + 图例格的占比卡片(概览与拨测记录共用)
├── page-summaries.tsx # 演示:各导航页的数据摘要(按导航项 id 选择)
├── list-page.tsx # 演示:列表页(summary 插槽 + 数据表格区块)
└── sample-data.ts # 演示用假数据演示相关的文件(dashboard-overview.tsx、overview-charts.tsx、distribution-card.tsx、page-summaries.tsx、list-page.tsx、sample-data.ts)只用来展示布局效果,接入真实数据后可以直接删除。
用法
app/(app)/layout.tsx 把导航数据和用户信息交给 AppShell,路由组 (app) 下的所有页面都会套上这层壳:
"use client"
import { AppShell } from "@/components/app-shell/app-shell"
import { navConfig } from "@/components/app-shell/nav-config"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<AppShell
nav={navConfig}
brand={{ name: "Acme 控制台", tagline: "企业级监控与运维" }}
user={{ name: "管理员", email: "admin@example.com" }}
onSignOut={() => {
// 调用你的认证服务退出登录
}}
>
{children}
</AppShell>
)
}导航数据里的图标是 React 组件(函数),无法从服务端组件传给客户端组件,所以这个布局需要
"use client"。页面本身仍然可以是服务端组件。导航里的其余链接由dashboard/[...slug]/page.tsx兜底,显示通用列表页;在app/(app)/dashboard/下新建同名路由(例如下文的system/users/page.tsx)就会替换掉对应的占位页。导航项改了地址或删掉后,兜底页会跟着导航数据走,不需要另外维护。
配置导航
导航数据都在 nav-config.ts,类型定义在 nav-types.ts:
import { LayoutDashboard, Settings, Users } from "lucide-react"
import type { NavConfig } from "./nav-types"
const navConfig: NavConfig = {
sections: [
{
id: "workspace",
label: "工作台", // 一级导航里的分组小标题
entries: [
// 叶子条目:点击直接跳转
{ id: "overview", label: "概览", href: "/dashboard", icon: LayoutDashboard },
// 模块条目:有 items,选中后出现二级侧栏
{
id: "system",
label: "系统管理",
icon: Settings,
description: "账号、权限与系统设置", // 二级侧栏顶部的副标题
items: [
{
id: "users",
label: "用户管理",
href: "/dashboard/system/users",
icon: Users,
description: "账号与角色分配", // 二级侧栏里的说明
badge: 3, // 可选角标
},
],
},
],
},
],
// 固定在一级导航底部的分组,可选
footer: { id: "resources", label: "资源与帮助", entries: [/* … */] },
}
export { navConfig }| 类型 | 字段 | 说明 |
|---|---|---|
NavLeaf | id label href icon | 叶子条目;description、badge 可选 |
NavModule | id label icon items | 模块;items 是 NavLeaf[],description 可选。一级导航里点击模块会跳到第一个子项 |
NavSection | id label entries | 一级导航的分组,entries 可以混排叶子和模块 |
NavConfig | sections footer? | footer 固定在一级导航底部 |
约定:
- 高亮匹配:
resolveNav(config, href)取href匹配最长的条目,/dashboard/system/users/12会匹配到/dashboard/system/users。比较时忽略查询串、哈希和末尾斜杠,开启trailingSlash的项目也能正常高亮。 - 面包屑自动由「分组 → 模块 → 页面」生成,窄屏只显示最后一级。
- 没有匹配到任何条目时,不显示二级侧栏和面包屑,布局仍然正常。
- 需要按权限过滤导航时,在传给
AppShell之前先过滤navConfig即可,组件本身不关心权限。
示例
在 aside 里放 AI 助手
aside 会渲染成 main 右侧的兄弟元素,和 main 一起在横向 flex 布局里分配宽度,高度占满顶栏以下的区域。放进来的面板应该:
- 根元素是
h-full的<aside>,自带宽度(如w-96 shrink-0),自己决定边框与背景; - 窄屏下是否隐藏由面板自己处理(例如加
hidden lg:flex)。
「AI 助手」按钮放进 headerActions,面板放进 aside,用一个状态位控制显隐:
"use client"
import { SparklesIcon } from "lucide-react"
import * as React from "react"
import { AppShell } from "@/components/app-shell/app-shell"
import { navConfig } from "@/components/app-shell/nav-config"
import { Button } from "@/components/ui/button"
export default function AppLayout({ children }: { children: React.ReactNode }) {
const [assistantOpen, setAssistantOpen] = React.useState(false)
return (
<AppShell
nav={navConfig}
headerActions={
<Button
variant={assistantOpen ? "secondary" : "ghost"}
aria-pressed={assistantOpen}
onClick={() => setAssistantOpen((open) => !open)}
>
<SparklesIcon data-icon="inline-start" />
AI 助手
</Button>
}
aside={assistantOpen ? <YourAssistantPanel /> : null}
>
{children}
</AppShell>
)
}站内的 AI 助手面板 区块在「嵌入」模式下就是这样一个自带宽度的 <aside>,可以直接放进 aside 插槽。
受控的侧栏折叠
collapsed / defaultCollapsed / onCollapsedChange 成对出现,同时支持受控与非受控。不传 collapsed 时组件自己维护,并把状态记在 localStorage(默认键 travinsy:app-shell:collapsed,可用 storageKey 修改);传了就完全由你决定,不再读写本地存储,也不注入首帧脚本:
const [collapsed, setCollapsed] = React.useState(false)
<AppShell nav={navConfig} collapsed={collapsed} onCollapsedChange={setCollapsed}>
{children}
</AppShell>受控模式下想避免首屏闪烁,要让首次渲染传入的值就是最终状态,例如在服务端从 cookie 读取后作为初始值传入;静态导出没有请求上下文,通常直接用默认的非受控模式即可。
首帧脚本:刷新不闪烁
静态导出的 HTML 在构建时生成,服务端不知道用户上次是否折叠过侧栏。如果等水合后再读 localStorage,已折叠的用户每次刷新都会先看到展开、再跳成折叠。AppShell 的做法与站点的颜色主题相同:
- 非受控时,
AppShell在根元素里的第一个子节点渲染一段很短的内联脚本(AppShellScript)。浏览器解析 HTML 时同步执行它,在首次绘制与水合之前读取localStorage,把结果写到根元素的data-collapsed;没有本地记录时保持defaultCollapsed。 - 侧栏宽度、顶栏品牌区、图标 / 文字显示等所有随折叠变化的样式都由
data-collapsed通过 CSS 决定(Tailwind 的group-data-[collapsed]/app-shell:变体),不依赖 React 状态,所以水合前就是正确的外观。 - 水合后 React 状态读到同一个值,与 DOM 一致;根元素带
suppressHydrationWarning,所以脚本改写属性不会触发水合警告。点击折叠按钮时 React 同步更新data-collapsed与localStorage。 - 宽度过渡只在用户点击折叠按钮后才开启(根元素出现
data-animate),首次渲染与水合不会播放动画。 - 通过路由跳转(客户端渲染)进入控制台时脚本不会执行,此时 React 直接按
localStorage渲染,同样没有闪烁。
整个机制写在 (app) 布局的 AppShell 里,不需要改根布局,也不需要在布局里手动放脚本;storageKey 会自动传给脚本。如果你不用 AppShell、而是自己组合 AppHeader / PrimaryNav,可以单独使用 AppShellScript,把它放在带 group/app-shell 与 data-collapsed 的容器里作为第一个子节点,并给该容器加 suppressHydrationWarning。
内联脚本在严格的内容安全策略(CSP,不允许
'unsafe-inline')下会被拦截,需要自行为脚本添加 nonce;脚本被拦截时退化为水合后再恢复折叠状态。
页面框架
列表页直接组合 数据表格区块,提供搜索、状态与应用筛选、排序、列设置、密度和网格分页,手机端自动切换卡片列表。摘要插槽保留在表格上方;180 条记录按固定公式生成,概览页仍使用原来的少量事件。
import { PageFrame } from "@/components/app-shell/page-header"
<PageFrame
section="系统管理"
title="用户管理"
description="账号与角色分配"
actions={<Button>新建</Button>}
>
{/* 表格、卡片…… */}
</PageFrame>自定义文案
默认文案集中在 config.ts,通过 AppShell 的 labels 覆盖并由页面组件继承。数据表格自身的通用文案通过 DataTable.labels 设置。
<AppShell
nav={navConfig}
labels={{ signOut: "Sign out", collapseSidebar: "Collapse", userMenu: "Account: {name}" }}
>
{children}
</AppShell>API
AppShell
导航数据:一级导航 + 二级(工作区)侧栏,见 nav-config.ts
类型NavConfig品牌区:名称、Logo、说明与跳转地址
类型AppShellBrand | undefined顶栏右侧的额外操作区,例如「AI 助手」按钮、服务状态
类型ReactNode主区域右侧插槽;agent-chat 嵌入面板在空间不足时覆盖显示,高度占满工作区
类型ReactNode当前登录用户,用于顶栏右侧的用户菜单;不传则不显示
类型AppShellUser | undefined额外的用户菜单项(DropdownMenuItem),放在「退出登录」之前
类型ReactNode点击「退出登录」时的回调
类型(() => void) | undefined当前地址,用于高亮导航;不传则取 usePathname()
类型string | undefined传入后拦截导航链接的默认跳转并回调,适合预览 / 单页场景;接入路由时不要传
类型((payload: { href: string; }) => void) | undefined侧栏是否折叠(受控);不传则内部维护并记住在 localStorage。 受控时不读写 localStorage、也不注入首帧脚本:想避免首屏闪烁, 请让首次渲染传入的值就是最终状态(例如服务端从 cookie 读取)。
类型boolean | undefined非受控时的初始折叠状态(无本地记录时生效)
类型boolean | undefined折叠状态变化时的回调,受控与非受控都会触发
类型((collapsed: boolean) => void) | undefined非受控时记住折叠状态的 localStorage 键
类型string | undefined是否显示亮暗模式切换(依赖 next-themes)
类型boolean | undefinedtrue覆盖默认文案,见 config.ts 的 DEFAULT_LABELS
类型Partial<AppShellLabels> | undefined主区域内容
类型ReactNodeAppShellScript
读取折叠状态的 localStorage 键,需与 AppShell 的 storageKey 一致
string"travinsy:app-shell:collapsed"通常不需要直接使用,AppShell 在非受控时会自动渲染它。
页面组件
| 组件 | 属性 | 说明 |
|---|---|---|
PageHeader | title description? section? actions? | 标题栏,滚动时吸附在主区域顶部 |
PageTabs | tabs value onValueChange trailing? labels? | 范围切换条(应用、环境等),用分段选择实现,单选语义 |
PageContent | — | 统一内边距与纵向间距 |
PageFrame | 同 PageHeader,另有 tabs? toolbar? | 把标题栏、标签条、工具栏、内容区一次组合 |
HeaderStatus | services checkedAt? labels? | 顶栏服务状态弹层,放进 headerActions |
UserMenu / ThemeToggle | labels? | 头像菜单与亮暗模式切换 |
PrimaryNav / WorkspaceSidebar / WorkspaceMobileNav | config 或 module active/activeId labels? | 一级导航与二级工作区导航,通常由 AppShell 组合 |
labels
全部键在 config.ts 的 DEFAULT_LABELS 里。含 {name}、{count}、{max}、{min} 的文案会在渲染时填入实际值。
外壳
键盘跳转链接
类型string"跳转到主内容"main 的 aria-label
string"主内容"一级导航名称、窄屏抽屉说明
类型string"主导航"一级导航名称、窄屏抽屉说明
类型string"选择要访问的页面"窄屏打开抽屉按钮
类型string"打开导航"折叠按钮名称,随状态切换
类型string"收起侧边栏"折叠按钮名称,随状态切换
类型string"展开侧边栏"面包屑的 aria-label
string"面包屑"品牌链接的 aria-label
string"{name},回到首页"二级导航与二级侧栏的 aria-label
string"{name}导航"二级导航与二级侧栏的 aria-label
string"{name}侧栏"亮暗模式按钮
类型string"切换亮暗模式"用户菜单
类型string"用户菜单:{name}"用户菜单
类型string"退出登录"顶栏服务状态
类型string"服务状态"顶栏服务状态
类型string"查看服务状态:{name}"顶栏服务状态
类型string"检查于 {name}"服务状态文字
类型string"可用"服务状态文字
类型string"需关注"服务状态文字
类型string"不可用"服务状态文字
类型string"未知"页面内容(演示页)
概览页标题栏
类型string"工作台"概览页标题栏
类型string"概览"概览页说明
类型string"近 24 小时的拨测质量与告警情况"概览页操作按钮
类型string"导出"概览页操作按钮
类型string"刷新"范围切换条的 aria-label
string"数据范围"统计卡片区的 aria-label
string"关键指标"统计卡片里迷你走势的读屏名称
类型string"{name}近 24 小时走势"响应耗时图表卡片
类型string"响应耗时趋势"响应耗时图表卡片
类型string"每小时 P50,单位 ms"图表序列名(提示框里显示)
类型string"P50 响应耗时"图表头部的指标名
类型string"峰值"图表头部的指标名
类型string"谷值"图表头部的指标名
类型string"均值"响应耗时图表的 aria-label
string"过去 24 小时响应耗时,最高 {max} 毫秒,最低 {min} 毫秒"事件表卡片与表格题注
类型string"最近事件"事件表卡片与表格题注
类型string"按时间倒序,共 {count} 条"事件表卡片与表格题注
类型string"最近拨测事件"列表页新建按钮
类型string"新建"应用筛选占位
类型string"全部应用"列表页搜索框
类型string"搜索应用或目标"列表页表格题注
类型string"{title}列表"空结果提示
类型string"没有匹配的记录"表头
类型string"应用"表头
类型string"目标"表头
类型string"状态"表头
类型string"耗时"表头
类型string"时间"事件状态徽标文字
类型string"正常"事件状态徽标文字
类型string"告警"事件状态徽标文字
类型string"错误"data-slot 与状态
可访问性
- 提供「跳转到主内容」链接,键盘 Tab 的第一个焦点;
main带aria-label。 - 一级导航、二级导航、面包屑都是带名称的
nav;当前页用aria-current="page",当前模块用aria-current="true"。 - 侧栏折叠按钮带
aria-expanded与随状态变化的名称;折叠后导航项仍有aria-label,并通过 Tooltip 显示名称。 - 窄屏抽屉是 Sheet(Base UI Dialog):Esc 关闭、焦点被困在抽屉内、关闭后焦点回到触发按钮。
- 纯图标按钮都有
aria-label;状态徽标同时用图标与文字,不只靠颜色。 - 表格有
caption与th scope="col";图表是role="img",带一段描述整体数据的aria-label,状态徽标用色点加文字;范围切换条是单选组,方向键切换。 - 过渡动画在系统开启「减少动态效果」时关闭。
注意事项
- 静态导出:布局完全在客户端运行,不使用 Server Actions 或
cookies(),可用于output: "export"。 - 折叠状态与水合:服务端输出使用
defaultCollapsed,首帧脚本在水合前按localStorage改写根元素的data-collapsed,详见上面的「首帧脚本」。折叠样式只依赖data-collapsed,自定义时请沿用group-data-[collapsed]/app-shell:变体,不要根据 React 状态切换结构,否则水合前外观会不一致。 - 布局高度:外层使用
h-svh,应放在不带其他固定高度父级的路由组布局里;预览用 iframe 的高度。 - 一级导航宽度只定义一处:展开 200px、折叠 60px 写在
config.ts的NAV_WIDTH_CLASS,app-shell.tsx的侧栏与app-header.tsx的品牌区都从这里取,改宽度只需改一处;窄屏品牌区的max-md:w-[60px]也在这个常量里。过渡类名在同文件的NAV_WIDTH_TRANSITION。 - 文案继承:
AppShell的labels通过 context 传遍整个外壳和主区域,页面里自己传的labels优先级更高。 - 颜色:来自项目的主题变量(
globals.css),到 主题 页挑一套复制进去,整个控制台自动换色。
主区域与助手的响应式布局
一级导航保留折叠偏好与首帧恢复,800px 以下使用抽屉导航。二级导航在 1280px 以下改为横向标签栏。扣除导航后,工作区达到 1240px 才并排展示 AI 助手(包含最大 640px 拖拽宽度,主内容至少 600px);不足时助手覆盖在右侧,768px 以下沿用底部 Sheet。关闭助手后主内容使用全部剩余空间。手机主内容随屏幕宽度收缩。
页头按主区域容器宽度换行,范围切换可横向滚动。统计卡按内容容器的 480px / 960px 阈值显示两列 / 四列,其余为单列;图表沿用容器查询。