跳至正文
模板

Console Shell企业控制台布局

提炼自企业监控平台的控制台外壳:固定顶栏、可折叠一级导航、二级工作区侧栏、独立滚动主区域,窄屏变抽屉,并预留右侧 aside 插槽(可放 AI 助手面板)。

正在加载…

AppShell 提炼自我的一个企业监控平台项目的整体布局,去掉了接口请求、权限过滤和登录逻辑,只留下布局与交互本身:

  • 固定顶栏:左侧品牌区宽度与导航对齐,右侧依次是 headerActions、亮暗模式切换、用户菜单;中间是自动生成的面包屑。
  • 一级导航:按分组排列,当前页高亮;可折叠为图标栏(名称出现在 Tooltip 里),折叠状态记在 localStorage,刷新后首帧就是上次的状态,不会闪烁。
  • 二级工作区侧栏:一级导航里的「模块」被选中时,右侧多出一栏,列出该模块下的页面。
  • 独立滚动的主区域:顶栏和侧栏固定,只有 main 滚动,切换页面自动回到顶部。
  • 右侧 aside 插槽:与 main 并排,高度占满顶栏以下的区域,用来放 AI 助手面板等。
  • 窄屏适配:宽度小于 768px 时,一级导航变成左侧 Sheet 抽屉,二级导航变成顶栏下方的横向标签条。
  • 页面侧附带 PageHeader、PageTabs、PageToolbar、PageFrame,对应原项目的页面框架、范围切换和列表工具栏。
  • 演示页直接用站内自研的数据展示组件拼成:
    • 概览页:指标是 统计卡片 加 迷你图 走势;图表卡片 里依次是响应耗时趋势、错误预算(刻度仪表)、状态码分布(环形图)、最慢接口排行与最近事件表;范围切换用 分段选择。
    • 拨测记录、接口探测、节点监控、事件与异常:各自带一组贴合主题的数据摘要(拨测量堆叠柱与异常原因环形图、耗时分布与 SLO 达标环、各区域 CPU / 内存进度环、事件趋势与处理进度),由 ListPage 的 summary 插槽放在列表上方。
    • 图表区用 Tailwind 的容器查询(@container)排版:主区域被右侧 aside(如 AI 助手面板)挤窄时自动改为单列,不只看浏览器视口宽度。
  • 颜色全部来自主题变量,切换颜色主题和亮暗模式后自动跟随,预览里可以直接试。

预览里没有真实路由,所以演示用 activeHref + onNavigate 在本地模拟导航;接入 Next.js 时这两个属性都不用传,组件会用 usePathname() 高亮当前页。

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/app-shell-01.json
会连带安装 shadcn 组件 button、avatar、breadcrumb、dropdown-menu、input、popover、sheet、tooltip、chart, 本站条目 stat-card、mini-chart、chart-card、grid-layout、flow-diagram、segmented-control。已存在的文件会询问是否覆盖。

会安装 shadcn 的 button、avatar、breadcrumb、dropdown-menu、input、popover、sheet、tooltip,本站的 统计卡片、迷你图、图表卡片 与 分段选择(概览页与范围切换用到),npm 包 lucide-react、next-themes,以及 hooks/use-controllable-state.ts。如果项目里已有同名文件,CLI 会询问是否覆盖。

亮暗模式按钮依赖项目里已有的 next-themes ThemeProvider;不需要的话给 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
├── page-toolbar.tsx           # 列表工具栏:总数、翻页、搜索、筛选插槽
├── 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) 下的所有页面都会套上这层壳:

app/(app)/layout.tsx
"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:

nav-config.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 }
类型字段说明
NavLeafid label href icon叶子条目;description、badge 可选
NavModuleid label icon items模块;items 是 NavLeaf[],description 可选。一级导航里点击模块会跳到第一个子项
NavSectionid label entries一级导航的分组,entries 可以混排叶子和模块
NavConfigsections footer?footer 固定在一级导航底部

约定:

  • 高亮匹配:resolveNav(config, href) 取 href 匹配最长的条目,/dashboard/system/users/12 会匹配到 /dashboard/system/users。比较时忽略查询串、哈希和末尾斜杠,开启 trailingSlash 的项目也能正常高亮。
  • 面包屑自动由「分组 → 模块 → 页面」生成,窄屏只显示最后一级。
  • 没有匹配到任何条目时,不显示二级侧栏和面包屑,布局仍然正常。
  • 需要按权限过滤导航时,在传给 AppShell 之前先过滤 navConfig 即可,组件本身不关心权限。

示例

在 aside 里放 AI 助手

aside 会渲染成 main 右侧的兄弟元素,和 main 一起在横向 flex 布局里分配宽度,高度占满顶栏以下的区域。放进来的面板应该:

  1. 根元素是 h-full 的 <aside>,自带宽度(如 w-96 shrink-0),自己决定边框与背景;
  2. 窄屏下是否隐藏由面板自己处理(例如加 hidden lg:flex)。

「AI 助手」按钮放进 headerActions,面板放进 aside,用一个状态位控制显隐:

app/(app)/layout.tsx
"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 的做法与站点的颜色主题相同:

  1. 非受控时,AppShell 在根元素里的第一个子节点渲染一段很短的内联脚本(AppShellScript)。浏览器解析 HTML 时同步执行它,在首次绘制与水合之前读取 localStorage,把结果写到根元素的 data-collapsed;没有本地记录时保持 defaultCollapsed。
  2. 侧栏宽度、顶栏品牌区、图标 / 文字显示等所有随折叠变化的样式都由 data-collapsed 通过 CSS 决定(Tailwind 的 group-data-[collapsed]/app-shell: 变体),不依赖 React 状态,所以水合前就是正确的外观。
  3. 水合后 React 状态读到同一个值,与 DOM 一致;根元素带 suppressHydrationWarning,所以脚本改写属性不会触发水合警告。点击折叠按钮时 React 同步更新 data-collapsed 与 localStorage。
  4. 宽度过渡只在用户点击折叠按钮后才开启(根元素出现 data-animate),首次渲染与水合不会播放动画。
  5. 通过路由跳转(客户端渲染)进入控制台时脚本不会执行,此时 React 直接按 localStorage 渲染,同样没有闪烁。

整个机制写在 (app) 布局的 AppShell 里,不需要改根布局,也不需要在布局里手动放脚本;storageKey 会自动传给脚本。如果你不用 AppShell、而是自己组合 AppHeader / PrimaryNav,可以单独使用 AppShellScript,把它放在带 group/app-shell 与 data-collapsed 的容器里作为第一个子节点,并给该容器加 suppressHydrationWarning。

内联脚本在严格的内容安全策略(CSP,不允许 'unsafe-inline')下会被拦截,需要自行为脚本添加 nonce;脚本被拦截时退化为水合后再恢复折叠状态。

页面框架

主区域里的页面可以用 page-header.tsx 和 page-toolbar.tsx 里的组件快速搭起来:

app/(app)/dashboard/system/users/page.tsx
import { PageFrame } from "@/components/app-shell/page-header"
import { PageToolbar } from "@/components/app-shell/page-toolbar"

<PageFrame
  section="系统管理"
  title="用户管理"
  description="账号与角色分配"
  actions={<Button>新建</Button>}
  toolbar={<PageToolbar total={128} page={1} pageSize={20} onPageChange={setPage} />}
>
  {/* 表格、卡片…… */}
</PageFrame>

自定义文案

用户可见文案集中在 config.ts 的 DEFAULT_LABELS,通过 labels 覆盖任意几项。AppShell 会把合并后的文案放进 context,所以主区域里的 PageHeader、PageToolbar、演示页面等组件会自动继承,不需要再逐个传 labels;单个组件也可以传自己的 labels 覆盖外层值。带占位符的文案会被填入实际名称:

<AppShell
  nav={navConfig}
  labels={{ signOut: "Sign out", collapseSidebar: "Collapse", userMenu: "Account: {name}" }}
>
  {children}
</AppShell>

API

AppShell

AppShellScript

通常不需要直接使用,AppShell 在非受控时会自动渲染它。

页面组件

组件属性说明
PageHeadertitle description? section? actions?标题栏,滚动时吸附在主区域顶部
PageTabstabs value onValueChange trailing? labels?范围切换条(应用、环境等),用分段选择实现,单选语义
PageToolbartotal page pageSize onPageChange search? onSearchChange? filters? labels?总数、翻页、搜索;状态完全受控
PageContent—统一内边距与纵向间距
PageFrame同 PageHeader,另有 tabs? toolbar?把标题栏、标签条、工具栏、内容区一次组合
HeaderStatusservices checkedAt? labels?顶栏服务状态弹层,放进 headerActions
UserMenu / ThemeTogglelabels?头像菜单与亮暗模式切换
PrimaryNav / WorkspaceSidebar / WorkspaceMobileNavconfig 或 module active/activeId labels?一级导航与二级工作区导航,通常由 AppShell 组合

labels

全部键在 config.ts 的 DEFAULT_LABELS 里。含 {name}、{count}、{max}、{min} 的文案会在渲染时填入实际值。

外壳

页面内容(演示页)

data-slot 与状态

根元素都带 data-slot,方便用选择器或 has-data-[slot=…] 定制:app-shell(data-collapsed、data-animate、data-has-aside、data-has-workspace)、app-shell-header、app-shell-sidebar、app-shell-primary-nav、app-shell-nav-item(data-active)、app-shell-workspace-sidebar、app-shell-workspace-item(data-active)、app-shell-main、app-shell-page-header、app-shell-page-toolbar 等。

可访问性

  • 提供「跳转到主内容」链接,键盘 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 阈值显示两列 / 四列,其余为单列;图表沿用容器查询。