"use client"
import type { ReactNode } from "react"
import { AppShell } from "@/components/app-shell/app-shell"
import { HeaderStatus } from "@/components/app-shell/header-status"
import { navConfig } from "@/components/app-shell/nav-config"
import { services } from "@/components/app-shell/sample-data"
/**
* 控制台布局:app/(app) 路由组下的所有页面都会套上这层壳。
* 导航数据里的图标是函数,不能从服务端组件传给客户端组件,所以这个布局是客户端组件。
* 把 brand、user、onSignOut 换成你自己的数据与登录逻辑。
* 侧栏折叠状态的首帧脚本由 AppShell 自动注入,这里和根布局都不需要额外接线。
*/
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<AppShell
nav={navConfig}
brand={{ name: "Acme 控制台", tagline: "企业级监控与运维", href: "/dashboard" }}
user={{ name: "管理员", email: "admin@example.com" }}
onSignOut={() => {
// TODO: 调用你的认证服务退出登录,然后跳转到 /login
}}
headerActions={<HeaderStatus services={services} />}
>
{children}
</AppShell>
)
}
import type { Metadata } from "next"
import { DashboardOverview } from "@/components/app-shell/dashboard-overview"
export const metadata: Metadata = {
title: "概览",
}
/** 控制台首页:概览仪表盘。按同样的方式在 app/(app)/ 下添加更多页面,它们会自动套上侧栏与顶栏。 */
export default function DashboardPage() {
return <DashboardOverview />
}
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { ListPage } from "@/components/app-shell/list-page"
import { navConfig } from "@/components/app-shell/nav-config"
import {
isModule,
normalizeHref,
resolveNav,
} from "@/components/app-shell/nav-types"
import { PageSummary } from "@/components/app-shell/page-summaries"
type Props = { params: Promise<{ slug: string[] }> }
const BASE = "/dashboard"
/** 导航里除概览以外、位于 /dashboard/ 下的全部页面地址 */
function sectionHrefs() {
const sections = [
...navConfig.sections,
...(navConfig.footer ? [navConfig.footer] : []),
]
return sections
.flatMap((section) => section.entries)
.flatMap((entry) => (isModule(entry) ? entry.items : [entry]))
.map((item) => normalizeHref(item.href))
.filter((href) => href.startsWith(`${BASE}/`))
}
/** 只认导航里登记过的地址;其他路径交给 404 */
function resolveSection(slug: string[]) {
const href = `${BASE}/${slug.join("/")}`
const active = resolveNav(navConfig, href)
return active && normalizeHref(active.item.href) === href ? active : undefined
}
export const dynamicParams = false
export function generateStaticParams() {
return sectionHrefs().map((href) => ({
slug: href.slice(BASE.length + 1).split("/"),
}))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const active = resolveSection((await params).slug)
return { title: active?.item.label }
}
/**
* 导航中其余页面的占位实现:按地址找到导航项,显示通用列表页与该页的数据摘要。
* 开发真实页面时,在 app/(app)/dashboard/ 下新建同名路由即可,具体路由优先于这个兜底页。
*/
export default async function SectionPage({ params }: Props) {
const active = resolveSection((await params).slug)
if (!active) notFound()
return (
<ListPage
section={active.trail.at(-2)}
title={active.item.label}
description={active.item.description}
summary={<PageSummary id={active.item.id} />}
/>
)
}
"use client"
import * as React from "react"
import { usePathname } from "next/navigation"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { TooltipProvider } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { AppHeader, type AppShellBrand } from "./app-header"
import { AppShellScript, type AppShellScriptProps } from "./app-shell-script"
import {
AppShellLabelsProvider,
DEFAULT_STORAGE_KEY,
NAV_WIDTH_CLASS,
NAV_WIDTH_TRANSITION,
useAppShellLabels,
type AppShellLabels,
} from "./config"
import type { NavLinkBehavior } from "./nav-link"
import { resolveNav, type NavConfig } from "./nav-types"
import { PrimaryNav } from "./primary-nav"
import { useStoredFlag } from "./use-stored-flag"
import type { AppShellUser } from "./user-menu"
import { WorkspaceMobileNav, WorkspaceSidebar } from "./workspace-sidebar"
type AppShellProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 导航数据:一级导航 + 二级(工作区)侧栏,见 nav-config.ts */
nav: NavConfig
/** 品牌区:名称、Logo、说明与跳转地址 */
brand?: AppShellBrand
/** 顶栏右侧的额外操作区,例如「AI 助手」按钮、服务状态 */
headerActions?: React.ReactNode
/** 主区域右侧插槽;agent-chat 嵌入面板在空间不足时覆盖显示,高度占满工作区 */
aside?: React.ReactNode
/** 当前登录用户,用于顶栏右侧的用户菜单;不传则不显示 */
user?: AppShellUser
/** 额外的用户菜单项(DropdownMenuItem),放在「退出登录」之前 */
userMenu?: React.ReactNode
/** 点击「退出登录」时的回调 */
onSignOut?: () => void
/** 当前地址,用于高亮导航;不传则取 usePathname() */
activeHref?: string
/** 传入后拦截导航链接的默认跳转并回调,适合预览 / 单页场景;接入路由时不要传 */
onNavigate?: (payload: { href: string }) => void
/**
* 侧栏是否折叠(受控);不传则内部维护并记住在 localStorage。
* 受控时不读写 localStorage、也不注入首帧脚本:想避免首屏闪烁,
* 请让首次渲染传入的值就是最终状态(例如服务端从 cookie 读取)。
*/
collapsed?: boolean
/** 非受控时的初始折叠状态(无本地记录时生效) */
defaultCollapsed?: boolean
/** 折叠状态变化时的回调,受控与非受控都会触发 */
onCollapsedChange?: (collapsed: boolean) => void
/** 非受控时记住折叠状态的 localStorage 键 */
storageKey?: string
/**
* 是否显示亮暗模式切换(依赖 next-themes)
* @default true
*/
themeToggle?: boolean
/** 覆盖默认文案,见 config.ts 的 DEFAULT_LABELS */
labels?: Partial<AppShellLabels>
/** 主区域内容 */
children: React.ReactNode
}
const defaultBrand: AppShellBrand = { name: "控制台" }
/**
* 企业控制台布局:固定顶栏 + 可折叠一级导航 + 可选二级工作区侧栏 + 独立滚动的主区域 + 右侧 aside 插槽。
* 窄屏(< 768px)一级导航变为左侧 Sheet 抽屉,二级导航变为顶栏下方的横向标签条。
*/
function AppShell({
className,
nav,
brand = defaultBrand,
headerActions,
aside,
user,
userMenu,
onSignOut,
activeHref,
onNavigate,
collapsed: collapsedProp,
defaultCollapsed = false,
onCollapsedChange,
storageKey = DEFAULT_STORAGE_KEY,
themeToggle = true,
labels,
children,
...props
}: AppShellProps) {
const text = useAppShellLabels(labels)
const pathname = usePathname()
const currentHref = activeHref ?? pathname
const active = React.useMemo(
() => resolveNav(nav, currentHref),
[nav, currentHref]
)
// 非受控时折叠状态来自 localStorage(水合安全);受控时完全由外部决定
const [stored, setStored] = useStoredFlag(storageKey, defaultCollapsed)
const [collapsed, setCollapsed] = useControllableState({
prop: collapsedProp ?? stored,
defaultProp: defaultCollapsed,
onChange: (next) => {
if (collapsedProp === undefined) setStored(next)
onCollapsedChange?.(next)
},
})
const [mobileOpen, setMobileOpen] = React.useState(false)
// 只有用户点击折叠后才开启宽度过渡,首次渲染与水合不播放动画
const [animate, setAnimate] = React.useState(false)
const mainRef = React.useRef<HTMLElement>(null)
React.useEffect(() => {
mainRef.current?.scrollTo({ top: 0, left: 0 })
}, [currentHref])
const navigation = React.useMemo<NavLinkBehavior>(
() => ({
intercept: Boolean(onNavigate),
onNavigate: (payload) => {
setMobileOpen(false)
onNavigate?.(payload)
},
}),
[onNavigate]
)
const group = active?.module
return (
<TooltipProvider>
<AppShellLabelsProvider labels={text}>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<div
data-slot="app-shell"
data-collapsed={collapsed ? "" : undefined}
data-animate={animate ? "" : undefined}
data-has-aside={aside ? "" : undefined}
data-has-workspace={group ? "" : undefined}
className={cn(
"group/app-shell flex h-svh flex-col overflow-hidden bg-muted/35 text-foreground",
className
)}
// 首帧脚本会在水合前改写 data-collapsed,以 DOM 为准
suppressHydrationWarning
{...props}
>
{/* 受控时折叠状态由外部决定,不能让脚本用本地记录覆盖 */}
{collapsedProp === undefined ? (
<AppShellScript storageKey={storageKey} />
) : null}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:ring-3 focus:ring-ring/50 focus:outline-none"
>
{text.skipToContent}
</a>
<AppHeader
brand={brand}
collapsed={collapsed}
onToggleCollapsed={() => {
setAnimate(true)
setCollapsed(!collapsed)
}}
trail={active?.trail ?? []}
actions={headerActions}
themeToggle={themeToggle}
user={user}
userMenu={userMenu}
onSignOut={onSignOut}
navigation={navigation}
labels={labels}
/>
{group ? (
<WorkspaceMobileNav
module={group}
activeId={active?.item.id}
navigation={navigation}
labels={labels}
/>
) : null}
<div className="flex min-h-0 flex-1">
<aside
data-slot="app-shell-sidebar"
data-collapsed={collapsed ? "" : undefined}
className={cn(
"hidden shrink-0 border-r border-sidebar-border bg-sidebar min-[800px]:flex",
NAV_WIDTH_CLASS.sidebar,
NAV_WIDTH_TRANSITION
)}
>
<PrimaryNav
config={nav}
active={active}
collapsed={collapsed}
navigation={navigation}
labels={labels}
/>
</aside>
{group ? (
<WorkspaceSidebar
module={group}
activeId={active?.item.id}
navigation={navigation}
labels={labels}
/>
) : null}
<div data-slot="app-shell-workspace" className="@container/workspace relative flex min-h-0 min-w-0 flex-1">
<main
data-slot="app-shell-main"
id="main-content"
ref={mainRef}
tabIndex={-1}
aria-label={text.mainContent}
className="@container/main min-h-0 min-w-0 flex-1 overflow-y-auto bg-background outline-none"
>
{children}
</main>
{/* 留足主区域 600px;助手最大拖拽宽度 640px,空间不足时覆盖显示。 */}
<div className="contents [&>[data-mode=embedded]]:absolute [&>[data-mode=embedded]]:inset-y-0 [&>[data-mode=embedded]]:right-0 [&>[data-mode=embedded]]:z-30 [&>[data-mode=embedded]]:max-w-full [&>[data-mode=embedded]]:shadow-xl @min-[1240px]/workspace:[&>[data-mode=embedded]]:relative @min-[1240px]/workspace:[&>[data-mode=embedded]]:shadow-none">{aside}</div>
</div>
</div>
<SheetContent
side="left"
className="gap-0 p-0 data-[side=left]:w-[280px] data-[side=left]:sm:max-w-[280px]"
>
<SheetHeader className="border-b border-border px-4 py-3">
<SheetTitle className="text-sm">{text.mainNavigation}</SheetTitle>
<SheetDescription className="sr-only">
{text.mainNavigationDescription}
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 bg-sidebar">
<PrimaryNav
config={nav}
active={active}
navigation={navigation}
labels={labels}
/>
</div>
</SheetContent>
</div>
</Sheet>
</AppShellLabelsProvider>
</TooltipProvider>
)
}
export { AppShell, AppShellScript }
export type {
AppShellBrand,
AppShellProps,
AppShellScriptProps,
AppShellUser,
}
"use client"
import * as React from "react"
import { PanelLeftIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { SheetTrigger } from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import {
formatLabel,
NAV_WIDTH_CLASS,
NAV_WIDTH_TRANSITION,
useAppShellLabels,
type AppShellLabels,
} from "./config"
import { NavLink, type NavLinkBehavior } from "./nav-link"
import { ThemeToggle } from "./theme-toggle"
import { UserMenu, type AppShellUser } from "./user-menu"
type AppShellBrand = {
name: string
/** Logo 节点,建议 32×32 的 SVG / 图片;不传则用名称首字生成方块 */
logo?: React.ReactNode
/** 名称下方的一行说明,仅在侧栏展开的桌面端显示 */
tagline?: string
/** 点击品牌区跳转的地址,默认 "/" */
href?: string
}
type AppHeaderProps = Omit<React.ComponentProps<"header">, "children"> & {
brand: AppShellBrand
collapsed: boolean
onToggleCollapsed: () => void
/** 面包屑:分组 → 模块 → 页面 */
trail: string[]
actions?: React.ReactNode
themeToggle: boolean
user?: AppShellUser
userMenu?: React.ReactNode
onSignOut?: () => void
navigation?: NavLinkBehavior
labels?: Partial<AppShellLabels>
}
/** 固定顶栏:左侧品牌区(宽度与一级导航对齐)+ 折叠按钮 + 面包屑 + 右侧操作区。 */
function AppHeader({
className,
brand,
collapsed,
onToggleCollapsed,
trail,
actions,
themeToggle,
user,
userMenu,
onSignOut,
navigation,
labels,
...props
}: AppHeaderProps) {
const text = useAppShellLabels(labels)
const toggleLabel = collapsed ? text.expandSidebar : text.collapseSidebar
return (
<header
data-slot="app-shell-header"
data-collapsed={collapsed ? "" : undefined}
className={cn(
"relative z-30 shrink-0 border-b border-sidebar-border bg-background",
className
)}
{...props}
>
<div className="flex h-14 items-center">
<div
className={cn(
"h-full shrink-0 border-r border-sidebar-border bg-sidebar px-3 sm:px-4",
NAV_WIDTH_CLASS.brand,
NAV_WIDTH_TRANSITION
)}
>
<NavLink
href={brand.href ?? "/"}
navigation={navigation}
aria-label={formatLabel(text.brandHome, brand.name)}
className={cn(
"flex h-full min-w-0 items-center gap-2.5 rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50 max-[800px]:justify-center group-data-[collapsed]/app-shell:justify-center"
)}
>
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{brand.logo ?? (
<span
aria-hidden="true"
className="flex size-full items-center justify-center bg-primary text-sm font-semibold text-primary-foreground"
>
{brand.name.trim().slice(0, 1).toUpperCase()}
</span>
)}
</span>
<span className="hidden min-w-0 leading-tight min-[800px]:block min-[800px]:group-data-[collapsed]/app-shell:hidden">
<span className="block truncate text-sm font-semibold">
{brand.name}
</span>
{brand.tagline ? (
<span className="block truncate text-[11px] text-muted-foreground">
{brand.tagline}
</span>
) : null}
</span>
</NavLink>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3 sm:px-4">
<SheetTrigger
render={
<Button
variant="ghost"
size="icon"
className="-ml-1 min-[800px]:hidden"
aria-label={text.openNavigation}
/>
}
>
<PanelLeftIcon />
</SheetTrigger>
<Button
variant="ghost"
size="icon"
className="-ml-1 hidden min-[800px]:inline-flex"
onClick={onToggleCollapsed}
aria-label={toggleLabel}
aria-expanded={!collapsed}
title={toggleLabel}
>
<PanelLeftIcon />
</Button>
{trail.length > 0 ? (
<Breadcrumb aria-label={text.breadcrumb} className="min-w-0">
<BreadcrumbList className="flex-nowrap gap-1 overflow-hidden">
{trail.map((label, index) => {
const last = index === trail.length - 1
return last ? (
<BreadcrumbItem
key={`${label}-${index}`}
className="min-w-0"
>
<BreadcrumbPage className="truncate font-semibold">
{label}
</BreadcrumbPage>
</BreadcrumbItem>
) : (
// 分隔符本身是 <li>,必须和 BreadcrumbItem 并列,嵌套会触发水合错误
<React.Fragment key={`${label}-${index}`}>
<BreadcrumbItem className="hidden shrink-0 lg:inline-flex">
{label}
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden shrink-0 lg:flex lg:items-center" />
</React.Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{actions}
{themeToggle ? <ThemeToggle labels={labels} /> : null}
{user ? (
<UserMenu user={user} onSignOut={onSignOut} labels={labels}>
{userMenu}
</UserMenu>
) : null}
</div>
</div>
</div>
</header>
)
}
export { AppHeader }
export type { AppHeaderProps, AppShellBrand }
import { DEFAULT_STORAGE_KEY } from "./config"
type AppShellScriptProps = {
/** 与 AppShell 的 `storageKey` 保持一致 */
storageKey?: string
}
/**
* 首帧脚本:在浏览器解析 HTML 时(早于首次绘制与水合)读取 localStorage,
* 把折叠状态写到所在元素(AppShell 根元素)的 `data-collapsed`,
* 侧栏宽度、图标栏等样式据此在水合前就是正确状态,刷新不再闪烁。
* 没有本地记录时不动,保持服务端渲染的 `defaultCollapsed`。
*
* 必须作为 AppShell 根元素的子节点渲染(AppShell 已自动放好),
* 脚本通过 `document.currentScript.parentElement` 找到根元素。
*/
function AppShellScript({ storageKey = DEFAULT_STORAGE_KEY }: AppShellScriptProps) {
// 键里的 "<" 转义,防止提前闭合 <script>
const key = JSON.stringify(storageKey).replace(/</g, "\\u003c")
const html = `(function(){try{var v=localStorage.getItem(${key});if(v===null)return;var e=document.currentScript.parentElement;if(v==="1")e.setAttribute("data-collapsed","");else e.removeAttribute("data-collapsed")}catch(_){}})()`
return (
<script
data-slot="app-shell-script"
// 服务端输出可执行脚本;客户端渲染(路由跳转进来)时改为 text/plain,
// 避免 React 对 <script> 的开发警告——此时 React 自己按 localStorage 渲染,无需脚本
type={typeof window === "undefined" ? "text/javascript" : "text/plain"}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export { AppShellScript }
export type { AppShellScriptProps }
"use client"
import * as React from "react"
import { ChevronRightIcon } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useAppShellLabels, type AppShellLabels } from "./config"
import { NavLink, type NavLinkBehavior } from "./nav-link"
import {
isModule,
moduleHref,
type NavConfig,
type NavEntry,
type NavSection,
type ResolvedNav,
} from "./nav-types"
type PrimaryNavProps = Omit<React.ComponentProps<"nav">, "children"> & {
config: NavConfig
/** 当前页面对应的导航位置,由 resolveNav 得到 */
active?: ResolvedNav
/**
* 图标栏模式:只显示图标,名称放到 Tooltip 与读屏文本里。
* 外观由祖先 `.group/app-shell[data-collapsed]` 决定(水合前即生效),
* 这个 prop 只负责 Tooltip 的开关;抽屉里的导航在 AppShell 之外,始终展开。
*/
collapsed?: boolean
navigation?: NavLinkBehavior
labels?: Partial<AppShellLabels>
}
/** 一级导航:分组标题 + 条目;模块条目指向其第一个子项,选中时左侧出现二级侧栏。 */
function PrimaryNav({
className,
config,
active,
collapsed = false,
navigation,
labels,
...props
}: PrimaryNavProps) {
const text = useAppShellLabels(labels)
function renderEntry(entry: NavEntry) {
const isGroup = isModule(entry)
const selected = isGroup
? active?.module?.id === entry.id
: active?.item.id === entry.id
const Icon = entry.icon
const badge = isGroup ? undefined : entry.badge
const link = (
<NavLink
data-slot="app-shell-nav-item"
data-active={selected ? "" : undefined}
href={isGroup ? moduleHref(entry) : entry.href}
navigation={navigation}
aria-current={selected ? (isGroup ? "true" : "page") : undefined}
className={cn(
"relative flex h-10 w-full min-w-0 items-center gap-3 rounded-md px-3 text-sm transition-colors outline-none group-data-[collapsed]/app-shell:justify-center group-data-[collapsed]/app-shell:px-2 focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
selected
? "bg-primary/10 font-medium text-sidebar-foreground shadow-[inset_3px_0_0_var(--primary)]"
: "text-sidebar-foreground/70 hover:bg-primary/5 hover:text-sidebar-foreground"
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
{/* 折叠与否只靠 CSS 切换,水合前就是正确的外观;折叠时名称保留为读屏文本 */}
<span className="min-w-0 flex-1 truncate group-data-[collapsed]/app-shell:sr-only">
{entry.label}
</span>
{badge !== undefined ? (
<>
<span className="rounded-full bg-primary/10 px-1.5 text-[11px] leading-5 font-medium text-primary tabular-nums group-data-[collapsed]/app-shell:hidden">
{badge}
</span>
<span
aria-hidden="true"
className="absolute top-2 right-2 hidden size-1.5 rounded-full bg-primary group-data-[collapsed]/app-shell:block"
/>
</>
) : null}
{isGroup ? (
<ChevronRightIcon
className="size-3 shrink-0 opacity-40 group-data-[collapsed]/app-shell:hidden"
aria-hidden="true"
/>
) : null}
</NavLink>
)
return (
<li key={entry.id}>
{/* 始终包一层 Tooltip,仅折叠时启用,避免折叠切换时重新挂载链接 */}
<Tooltip disabled={!collapsed}>
<TooltipTrigger render={link} />
<TooltipContent side="right">{entry.label}</TooltipContent>
</Tooltip>
</li>
)
}
function renderSection(section: NavSection) {
return (
<section key={section.id} aria-label={section.label}>
<h2
className="flex h-8 items-center px-3 text-[11px] font-medium text-sidebar-foreground/50 group-data-[collapsed]/app-shell:sr-only"
>
{section.label}
</h2>
<ul className="flex flex-col gap-1">
{section.entries.map(renderEntry)}
</ul>
</section>
)
}
return (
<nav
data-slot="app-shell-primary-nav"
data-collapsed={collapsed ? "" : undefined}
aria-label={text.mainNavigation}
className={cn("flex h-full min-h-0 flex-1 flex-col", className)}
{...props}
>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-2 py-4">
{config.sections.map(renderSection)}
</div>
{config.footer ? (
<div className="shrink-0 border-t border-sidebar-border px-2 py-3">
{renderSection(config.footer)}
</div>
) : null}
</nav>
)
}
export { PrimaryNav }
export type { PrimaryNavProps }
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatLabel, useAppShellLabels, type AppShellLabels } from "./config"
import { NavLink, type NavLinkBehavior } from "./nav-link"
import type { NavModule } from "./nav-types"
type WorkspaceNavProps = Omit<React.ComponentProps<"nav">, "children"> & {
module: NavModule
/** 当前选中的子项 id */
activeId?: string
navigation?: NavLinkBehavior
labels?: Partial<AppShellLabels>
}
function WorkspaceNav({
className,
module: group,
activeId,
navigation,
labels,
mobile = false,
...props
}: WorkspaceNavProps & { mobile?: boolean }) {
const text = useAppShellLabels(labels)
const navRef = React.useRef<HTMLElement>(null)
// 移动端是横向滚动条:让当前项保持在可视区域中间
React.useEffect(() => {
const nav = navRef.current
if (!mobile || !nav) return
function reveal() {
const current = nav!.querySelector<HTMLElement>('a[aria-current="page"]')
if (current && nav!.clientWidth) {
nav!.scrollLeft =
current.offsetLeft -
nav!.offsetLeft -
(nav!.clientWidth - current.clientWidth) / 2
}
}
reveal()
const observer = new ResizeObserver(reveal)
observer.observe(nav)
return () => observer.disconnect()
}, [activeId, mobile])
return (
<nav
data-slot="app-shell-workspace-nav"
data-mobile={mobile ? "" : undefined}
ref={navRef}
aria-label={formatLabel(text.workspaceNavigation, group.label)}
className={cn(
"flex",
mobile
? "h-10 shrink-0 items-center gap-1.5 overflow-x-auto overflow-y-hidden border-b border-border bg-muted/20 px-3 [scrollbar-width:none] xl:hidden [&::-webkit-scrollbar]:hidden"
: "min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-3 py-4",
className
)}
{...props}
>
{group.items.map((item) => {
const active = item.id === activeId
const Icon = item.icon
return (
<NavLink
key={item.id}
data-slot="app-shell-workspace-item"
data-active={active ? "" : undefined}
href={item.href}
navigation={navigation}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-w-0 shrink-0 items-center rounded-md transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
mobile
? "h-7 gap-1.5 px-2.5 text-xs"
: "min-h-14 gap-2.5 px-3 py-2.5 text-sm",
active
? "bg-primary/10 font-medium text-foreground shadow-[inset_3px_0_0_var(--primary)]"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon
aria-hidden="true"
className={mobile ? "size-3.5 shrink-0" : "size-4 shrink-0"}
/>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate">{item.label}</span>
{!mobile && item.description ? (
<span className="mt-0.5 block truncate text-[11px] font-normal text-muted-foreground">
{item.description}
</span>
) : null}
</span>
{!mobile && item.badge !== undefined ? (
<span className="rounded-full bg-primary/10 px-1.5 text-[11px] leading-5 font-medium text-primary tabular-nums">
{item.badge}
</span>
) : null}
</NavLink>
)
})}
</nav>
)
}
/** 窄屏下二级导航:Header 下方的横向滚动标签条。 */
function WorkspaceMobileNav(props: WorkspaceNavProps) {
return <WorkspaceNav {...props} mobile />
}
/** 二级(工作区)侧栏:模块标题 + 子项列表,宽屏显示在一级导航右侧。 */
function WorkspaceSidebar({
className,
...props
}: WorkspaceNavProps) {
const { module: group, labels } = props
const text = useAppShellLabels(labels)
const GroupIcon = group.icon
return (
<aside
data-slot="app-shell-workspace-sidebar"
aria-label={formatLabel(text.workspaceSidebar, group.label)}
className={cn(
"hidden h-full min-h-0 w-[220px] shrink-0 flex-col border-r border-border bg-muted/20 xl:flex",
className
)}
>
<div className="flex h-[76px] shrink-0 items-center gap-2.5 border-b border-border px-5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-primary/20 bg-primary/10 text-primary">
<GroupIcon className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{group.label}</p>
{group.description ? (
<p className="truncate text-[11px] text-muted-foreground">
{group.description}
</p>
) : null}
</div>
</div>
<WorkspaceNav {...props} />
</aside>
)
}
export { WorkspaceMobileNav, WorkspaceSidebar }
export type { WorkspaceNavProps }
"use client"
import * as React from "react"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { useAppShellLabels, type AppShellLabels } from "./config"
type PageHeaderProps = Omit<React.ComponentProps<"header">, "title"> & {
title: string
description?: string
/** 标题上方的小标题,通常是所属分组 */
section?: string
/** 右侧操作区,例如「新建」「导出」按钮 */
actions?: React.ReactNode
}
/** 页面标题栏:分组小标题 + 标题 + 说明 + 右侧操作区,滚动时吸附在主区域顶部。 */
function PageHeader({
className,
title,
description,
section,
actions,
...props
}: PageHeaderProps) {
return (
<header
data-slot="app-shell-page-header"
className={cn(
"sticky top-0 z-20 flex min-h-[76px] shrink-0 flex-wrap items-center justify-between gap-4 border-b border-border bg-background/95 px-4 py-3 backdrop-blur @lg/main:px-6",
className
)}
{...props}
>
<div className="min-w-0 basis-full @min-[600px]/main:basis-auto @min-[600px]/main:flex-1">
{section ? (
<p className="flex items-center gap-2 text-[11px] font-medium whitespace-nowrap tracking-widest text-muted-foreground">
<span
className="size-1.5 rounded-full bg-primary"
aria-hidden="true"
/>
{section}
</p>
) : null}
<div className="mt-1 flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="shrink-0 whitespace-nowrap text-xl font-semibold tracking-tight @lg/main:text-2xl">
{title}
</h1>
{description ? (
<p
className="hidden min-w-0 truncate text-xs text-muted-foreground @min-[600px]/main:block"
title={description}
>
{description}
</p>
) : null}
</div>
{description ? (
<p className="mt-1 truncate text-[11px] text-muted-foreground @min-[600px]/main:hidden">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
) : null}
</header>
)
}
type PageTab = { value: string; label: string; icon?: LucideIcon }
type PageTabsProps = Omit<React.ComponentProps<"div">, "onChange"> & {
tabs: PageTab[]
value: string
onValueChange: (value: string) => void
/** 右侧附加内容,例如时间范围说明 */
trailing?: React.ReactNode
labels?: Partial<AppShellLabels>
}
/**
* 范围切换条(如「全局总览 / 应用 A / 应用 B」):用分段选择实现。
* 它切换的是同一页内容的数据范围,没有对应的面板,所以用单选语义而不是 tab;窄屏可横向滚动。
*/
function PageTabs({
className,
tabs,
value,
onValueChange,
trailing,
labels,
...props
}: PageTabsProps) {
const text = useAppShellLabels(labels)
return (
<div
data-slot="app-shell-page-tabs"
className={cn(
"flex min-w-0 items-center gap-3 border-b border-border px-4 py-2 @lg/main:px-6",
className
)}
{...props}
>
<div className="min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<SegmentedControl
size="sm"
aria-label={text.scopeSwitch}
className="w-max min-w-max"
value={value}
onValueChange={onValueChange}
options={tabs.map(({ value: tab, label, icon: Icon }) => ({
value: tab,
label,
icon: Icon ? <Icon /> : undefined,
}))}
/>
</div>
{trailing ? (
<div className="ml-auto hidden shrink-0 text-[11px] text-muted-foreground @min-[700px]/main:block">
{trailing}
</div>
) : null}
</div>
)
}
/** 页面内容区:统一内边距与纵向间距。 */
function PageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="app-shell-page-content"
className={cn("flex min-w-0 flex-col gap-5 p-4 @lg/main:p-6", className)}
{...props}
/>
)
}
type PageFrameProps = Omit<React.ComponentProps<"div">, "title"> &
Pick<PageHeaderProps, "title" | "description" | "section" | "actions"> & {
/** 标题栏下方的标签条,通常是 <PageTabs /> */
tabs?: React.ReactNode
/** 标题栏下方的工具栏,通常是 <PageToolbar /> */
toolbar?: React.ReactNode
}
/**
* 页面框架:标题栏 + 可选标签条 + 可选工具栏 + 内容区,一次组合完成。
* 需要更多控制时直接使用 PageHeader / PageTabs / PageContent。
*/
function PageFrame({
className,
title,
description,
section,
actions,
tabs,
toolbar,
children,
...props
}: PageFrameProps) {
return (
<div
data-slot="app-shell-page-frame"
className={cn("flex min-w-0 flex-col", className)}
{...props}
>
<PageHeader
title={title}
description={description}
section={section}
actions={actions}
/>
{tabs}
{toolbar}
<PageContent>{children}</PageContent>
</div>
)
}
export { PageContent, PageFrame, PageHeader, PageTabs }
export type { PageFrameProps, PageHeaderProps, PageTab, PageTabsProps }
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { useAppShellLabels, type AppShellLabels } from "./config"
type PageToolbarProps = Omit<React.ComponentProps<"section">, "children"> & {
total: number
page: number
pageSize: number
onPageChange: (page: number) => void
search?: string
onSearchChange?: (value: string) => void
/** 搜索框的无障碍名称与占位文字,默认取 labels.search */
searchLabel?: string
/** 搜索框旁边的筛选控件,例如 Select、Popover */
filters?: React.ReactNode
labels?: Partial<AppShellLabels>
}
/**
* 列表页工具栏:总数 + 翻页 + 搜索 + 自定义筛选项(filters 插槽)。
* 状态完全受控,数据请求与过滤逻辑由页面自己负责。
*/
function PageToolbar({
className,
total,
page,
pageSize,
onPageChange,
search,
onSearchChange,
searchLabel,
filters,
labels,
...props
}: PageToolbarProps) {
const text = useAppShellLabels(labels)
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const searchName = searchLabel ?? text.search
return (
<section
data-slot="app-shell-page-toolbar"
aria-label={text.pageToolbar}
className={cn(
"flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-x-6 gap-y-3 border-b border-border px-4 py-3 sm:px-6",
className
)}
{...props}
>
<div className="flex items-center gap-3">
<span className="text-xs font-medium tabular-nums">
{total} {text.totalUnit}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label={text.previousPage}
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeftIcon />
</Button>
<span className="min-w-14 text-center text-xs text-muted-foreground tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="icon-sm"
aria-label={text.nextPage}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
<ChevronRightIcon />
</Button>
</div>
</div>
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:w-auto">
{onSearchChange ? (
<div className="relative min-w-36 flex-1 sm:w-64 sm:flex-none">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
value={search ?? ""}
onChange={(event) => onSearchChange(event.target.value)}
aria-label={searchName}
placeholder={`${searchName}…`}
className="pl-8 text-xs md:text-xs"
/>
</div>
) : null}
{filters}
</div>
</section>
)
}
export { PageToolbar }
export type { PageToolbarProps }
"use client"
import * as React from "react"
import {
ActivityIcon,
AlertTriangleIcon,
CheckCircle2Icon,
CircleDashedIcon,
XCircleIcon,
type LucideIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { formatLabel, useAppShellLabels, type AppShellLabels } from "./config"
type ServiceState = "healthy" | "warning" | "unavailable" | "unknown"
type ServiceStatus = {
id: string
label: string
state: ServiceState
detail?: string
}
type HeaderStatusProps = {
services: ServiceStatus[]
/** 最近一次检查时间的展示文字,例如「14:32:05」 */
checkedAt?: string
className?: string
labels?: Partial<AppShellLabels>
}
// 状态靠「图标 + 文字」区分,颜色只用主题变量,换颜色主题后依然可辨
const stateStyles: Record<
ServiceState,
{ label: keyof AppShellLabels; icon: LucideIcon; className: string }
> = {
healthy: {
label: "stateHealthy",
icon: CheckCircle2Icon,
className: "text-primary",
},
warning: {
label: "stateWarning",
icon: AlertTriangleIcon,
className: "text-foreground",
},
unavailable: {
label: "stateUnavailable",
icon: XCircleIcon,
className: "text-destructive",
},
unknown: {
label: "stateUnknown",
icon: CircleDashedIcon,
className: "text-muted-foreground",
},
}
const severity: ServiceState[] = ["unavailable", "warning", "unknown", "healthy"]
/** 顶栏服务状态:按钮显示整体状态,弹层列出各服务。数据由外部传入(轮询、SSE 等自行实现)。 */
function HeaderStatus({
services,
checkedAt,
className,
labels,
}: HeaderStatusProps) {
const text = useAppShellLabels(labels)
const overall =
severity.find((state) => services.some((s) => s.state === state)) ??
"unknown"
const Overall = stateStyles[overall]
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
className={cn("gap-1.5 px-2 text-muted-foreground", className)}
aria-label={formatLabel(
text.serviceStatusAria,
text[Overall.label]
)}
/>
}
>
<span className="relative flex size-4 items-center justify-center">
<ActivityIcon aria-hidden="true" />
<Overall.icon
aria-hidden="true"
className={cn(
"absolute -top-1 -right-1 size-2.5 rounded-full bg-background",
Overall.className
)}
/>
</span>
<span className="hidden text-xs xl:inline">{text.serviceStatus}</span>
</PopoverTrigger>
<PopoverContent
data-slot="app-shell-header-status"
align="end"
className="w-72 max-w-[calc(100vw-1rem)]"
>
<p className="text-sm font-medium">{text.serviceStatus}</p>
<ul className="divide-y divide-border">
{services.map((service) => {
const state = stateStyles[service.state]
return (
<li key={service.id} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2 text-xs">
<span>{service.label}</span>
<span
className={cn("flex items-center gap-1.5", state.className)}
>
<state.icon className="size-3.5" aria-hidden="true" />
{text[state.label]}
</span>
</div>
{service.detail ? (
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{service.detail}
</p>
) : null}
</li>
)
})}
</ul>
{checkedAt ? (
<p className="text-[11px] text-muted-foreground">
{formatLabel(text.checkedAt, checkedAt)}
</p>
) : null}
</PopoverContent>
</Popover>
)
}
export { HeaderStatus }
export type { HeaderStatusProps, ServiceState, ServiceStatus }
"use client"
import * as React from "react"
import { LogOutIcon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { formatLabel, useAppShellLabels, type AppShellLabels } from "./config"
type AppShellUser = {
name: string
email?: string
avatar?: string
}
type UserMenuProps = {
user: AppShellUser
onSignOut?: () => void
labels?: Partial<AppShellLabels>
/** 额外的菜单项,放在账号信息与「退出登录」之间,用 DropdownMenuItem */
children?: React.ReactNode
}
/** 头像 + 下拉菜单:账号信息、自定义菜单项(children)、退出登录。 */
function UserMenu({ user, onSignOut, labels, children }: UserMenuProps) {
const text = useAppShellLabels(labels)
const initial = user.name.trim().slice(0, 1).toUpperCase() || "U"
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="rounded-full"
aria-label={formatLabel(text.userMenu, user.name)}
/>
}
>
<Avatar size="sm">
{user.avatar ? <AvatarImage src={user.avatar} alt="" /> : null}
<AvatarFallback className="bg-primary text-primary-foreground">
{initial}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent
data-slot="app-shell-user-menu"
align="end"
className="w-56"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5 px-2 py-1.5 font-normal">
<span className="truncate text-sm font-medium text-foreground">
{user.name}
</span>
{user.email ? (
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
) : null}
</DropdownMenuLabel>
</DropdownMenuGroup>
{children ? (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>{children}</DropdownMenuGroup>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onSignOut}>
<LogOutIcon />
{text.signOut}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { UserMenu }
export type { AppShellUser, UserMenuProps }
"use client"
import * as React from "react"
import { MoonIcon, SunIcon } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { useAppShellLabels, type AppShellLabels } from "./config"
/** 亮 / 暗模式切换,依赖项目里已有的 next-themes ThemeProvider。用 CSS 切换图标,避免水合不一致。 */
function ThemeToggle({
labels,
...props
}: React.ComponentProps<typeof Button> & {
labels?: Partial<AppShellLabels>
}) {
const { resolvedTheme, setTheme } = useTheme()
const { toggleTheme } = useAppShellLabels(labels)
return (
<Button
data-slot="app-shell-theme-toggle"
variant="ghost"
size="icon"
aria-label={toggleTheme}
title={toggleTheme}
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
{...props}
>
<SunIcon className="dark:hidden" />
<MoonIcon className="hidden dark:block" />
</Button>
)
}
export { ThemeToggle }
"use client"
import * as React from "react"
import Link from "next/link"
/** 导航链接的统一行为,由 AppShell 创建后往下传。 */
type NavLinkBehavior = {
/** 点击任一导航链接后触发:关闭移动端抽屉、通知外部当前地址等 */
onNavigate?: (payload: { href: string }) => void
/** 为 true 时拦截浏览器默认跳转,由 onNavigate 自行处理(预览、单页演示用) */
intercept?: boolean
}
type NavLinkProps = Omit<React.ComponentProps<typeof Link>, "href"> & {
href: string
navigation?: NavLinkBehavior
}
/** 对 next/link 的薄封装:保留 Cmd/Ctrl 点击等原生行为,只处理普通左键点击。 */
function NavLink({
href,
navigation,
onClick,
prefetch,
...props
}: NavLinkProps) {
function handleClick(event: React.MouseEvent<HTMLAnchorElement>) {
onClick?.(event)
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
if (navigation?.intercept) event.preventDefault()
navigation?.onNavigate?.({ href })
}
return (
<Link
data-slot="app-shell-nav-link"
href={href}
onClick={handleClick}
// 拦截跳转时地址只是标识(预览、单页演示里这些路由并不存在),不要预取,免得请求出一串 404
prefetch={navigation?.intercept ? false : prefetch}
{...props}
/>
)
}
export { NavLink }
export type { NavLinkBehavior, NavLinkProps }
import type { LucideIcon } from "lucide-react"
/** 叶子导航项:点击后直接跳转。 */
type NavLeaf = {
id: string
label: string
href: string
icon: LucideIcon
/** 二级侧栏里显示在标题下方的一行说明 */
description?: string
/** 右侧角标,例如未读数 */
badge?: string | number
}
/** 模块:在一级导航里占一行,进入后左侧出现二级(工作区)侧栏。 */
type NavModule = {
id: string
label: string
icon: LucideIcon
/** 二级侧栏顶部的副标题 */
description?: string
items: NavLeaf[]
}
type NavEntry = NavLeaf | NavModule
/** 一级导航的分组:带小标题的一组条目。 */
type NavSection = {
id: string
label: string
entries: NavEntry[]
}
type NavConfig = {
/** 一级导航分组,自上而下排列 */
sections: NavSection[]
/** 固定在一级导航底部的分组(如「资源与帮助」),可选 */
footer?: NavSection
}
type ResolvedNav = {
section: NavSection
/** 当前条目所属的模块;叶子条目直接挂在分组下时为 undefined */
module?: NavModule
item: NavLeaf
/** 面包屑:分组 → 模块 → 页面 */
trail: string[]
}
function isModule(entry: NavEntry): entry is NavModule {
return "items" in entry
}
/** 去掉查询串、哈希与末尾斜杠,便于和 href 比较(项目使用 trailingSlash 时尤其需要)。 */
function normalizeHref(href: string) {
const path = href.split(/[?#]/)[0] ?? ""
return path.length > 1 ? path.replace(/\/+$/, "") : path || "/"
}
function matches(current: string, href: string) {
const target = normalizeHref(href)
return current === target || current.startsWith(`${target}/`)
}
/** 模块在一级导航里指向的地址:第一个子项。 */
function moduleHref(module: NavModule) {
return module.items[0]?.href ?? "#"
}
/** 根据当前地址找到对应的分组、模块、页面;取匹配最长的 href,未命中返回 undefined。 */
function resolveNav(
config: NavConfig,
href: string,
): ResolvedNav | undefined {
const current = normalizeHref(href)
let best: ResolvedNav | undefined
let bestLength = -1
const visit = (section: NavSection, item: NavLeaf, module?: NavModule) => {
if (!matches(current, item.href)) return
const length = normalizeHref(item.href).length
if (length <= bestLength) return
bestLength = length
best = {
section,
module,
item,
trail: [section.label, ...(module ? [module.label] : []), item.label],
}
}
for (const section of [...config.sections, ...(config.footer ? [config.footer] : [])]) {
for (const entry of section.entries) {
if (isModule(entry)) {
for (const item of entry.items) visit(section, item, entry)
} else {
visit(section, entry)
}
}
}
return best
}
export { isModule, moduleHref, normalizeHref, resolveNav }
export type {
NavConfig,
NavEntry,
NavLeaf,
NavModule,
NavSection,
ResolvedNav,
}
import {
Activity,
BellRing,
BookOpen,
ClipboardList,
FileText,
Gauge,
KeyRound,
LayoutDashboard,
LifeBuoy,
Radar,
ScrollText,
Server,
Settings,
ShieldCheck,
Users,
Wrench,
} from "lucide-react"
import type { NavConfig } from "./nav-types"
/**
* 导航数据集中在这里:改这个文件就能换掉整套导航。
*
* - sections[].entries 里的叶子条目 → 一级导航里的一行,点击直接跳转;
* - 带 items 的条目是「模块」→ 一级导航里的一行,进入后出现二级(工作区)侧栏;
* - footer 固定在一级导航底部。
* 图标统一用 lucide-react。
*/
const navConfig: NavConfig = {
sections: [
{
id: "workspace",
label: "工作台",
entries: [
{
id: "overview",
label: "概览",
href: "/dashboard",
icon: LayoutDashboard,
},
{
id: "records",
label: "拨测记录",
href: "/dashboard/records",
icon: FileText,
},
{
id: "interfaces",
label: "接口探测",
href: "/dashboard/interfaces",
icon: Radar,
},
{
id: "nodes",
label: "节点监控",
href: "/dashboard/nodes",
icon: Server,
},
{
id: "events",
label: "事件与异常",
href: "/dashboard/events",
icon: Activity,
},
],
},
{
id: "management",
label: "业务管理",
entries: [
{
id: "operations",
label: "运维管理",
icon: Wrench,
description: "告警、通知与维护配置",
items: [
{
id: "alert-rules",
label: "告警规则",
href: "/dashboard/operations/alert-rules",
icon: Gauge,
description: "告警条件与站内提醒",
},
{
id: "incidents",
label: "告警事件",
href: "/dashboard/operations/incidents",
icon: BellRing,
description: "触发周期与恢复记录",
badge: 3,
},
{
id: "maintenance",
label: "维护窗口",
href: "/dashboard/operations/maintenance",
icon: ClipboardList,
description: "维护范围与抑制时段",
},
],
},
{
id: "system",
label: "系统管理",
icon: ShieldCheck,
description: "账号、权限与系统设置",
items: [
{
id: "users",
label: "用户管理",
href: "/dashboard/system/users",
icon: Users,
description: "账号与角色分配",
},
{
id: "roles",
label: "角色权限",
href: "/dashboard/system/roles",
icon: KeyRound,
description: "访问权限与首页模块",
},
{
id: "settings",
label: "系统配置",
href: "/dashboard/system/settings",
icon: Settings,
description: "平台配置与运行约定",
},
{
id: "audit-logs",
label: "审计日志",
href: "/dashboard/system/audit-logs",
icon: ScrollText,
description: "操作记录与完整上下文",
},
],
},
],
},
],
footer: {
id: "resources",
label: "资源与帮助",
entries: [
{
id: "docs",
label: "使用文档",
href: "/dashboard/docs",
icon: BookOpen,
},
{
id: "support",
label: "帮助与反馈",
href: "/dashboard/support",
icon: LifeBuoy,
},
],
},
}
export { navConfig }
"use client"
import * as React from "react"
// localStorage 不可用(隐私模式等)时退回内存,保证同一会话内状态仍然生效
const memory = new Map<string, boolean>()
const listeners = new Set<() => void>()
function subscribe(listener: () => void) {
listeners.add(listener)
window.addEventListener("storage", listener)
return () => {
listeners.delete(listener)
window.removeEventListener("storage", listener)
}
}
function read(key: string, fallback: boolean) {
const cached = memory.get(key)
if (cached !== undefined) return cached
try {
const stored = window.localStorage.getItem(key)
return stored === null ? fallback : stored === "1"
} catch {
return fallback
}
}
/**
* 持久化到 localStorage 的布尔状态。
* 服务端与水合阶段始终返回 fallback,水合完成后再读取本地值,避免水合不一致。
*/
function useStoredFlag(key: string, fallback = false) {
const value = React.useSyncExternalStore(
subscribe,
() => read(key, fallback),
() => fallback
)
const setValue = React.useCallback(
(next: boolean) => {
memory.set(key, next)
try {
window.localStorage.setItem(key, next ? "1" : "0")
} catch {
// 忽略:已写入内存
}
listeners.forEach((listener) => listener())
},
[key]
)
return [value, setValue] as const
}
export { useStoredFlag }
"use client"
import { DownloadIcon, RefreshCwIcon } from "lucide-react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { MiniChart } from "@/components/ui/mini-chart/mini-chart"
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
ChartSeries,
ChartTable,
type ChartTableColumn,
} from "@/components/ui/chart-card/chart-card"
import {
StatBadge,
StatCard,
StatHeader,
StatIcon,
StatLabel,
StatTrend,
StatValue,
type StatStatusName,
} from "@/components/ui/stat-card/stat-card"
import { formatLabel, useAppShellLabels } from "./config"
import {
ErrorBudgetCard,
SlowestEndpointsCard,
StatusCodeCard,
} from "./overview-charts"
import { PageContent, PageHeader, PageTabs } from "./page-header"
import {
dateRange,
events,
latencySeries,
scopes,
stats,
type EventRow,
type EventStatus,
} from "./sample-data"
// 事件状态对应到统计卡片的三档状态:正常 / 关注 / 风险
const statusNames = {
ok: "ok",
warning: "watch",
error: "risk",
} satisfies Record<EventStatus, StatStatusName>
/** 状态徽标:色点 + 文字,不只依赖颜色。 */
function StatusBadge({ status }: { status: EventStatus }) {
const text = useAppShellLabels()
const label = {
ok: text.eventOk,
warning: text.eventWarning,
error: text.eventError,
} satisfies Record<EventStatus, string>
return (
<StatBadge
data-slot="app-shell-status-badge"
data-status={status}
status={statusNames[status]}
>
{label[status]}
</StatBadge>
)
}
/** 事件明细表:仪表盘与列表页共用,仪表盘多一列耗时。 */
function EventsTable({
rows,
showLatency = false,
caption,
}: {
rows: EventRow[]
showLatency?: boolean
caption: string
}) {
const text = useAppShellLabels()
const columns: ChartTableColumn[] = [
{ key: "app", header: text.columnApp },
{ key: "target", header: text.columnTarget },
{ key: "status", header: text.columnStatus },
...(showLatency
? [{ key: "latency", header: text.columnLatency, align: "end" as const }]
: []),
{ key: "time", header: text.columnTime, align: "end" },
]
if (rows.length === 0) {
return (
<p
data-slot="events-table-empty"
className="py-10 text-center text-sm text-muted-foreground"
>
{text.emptyRows}
</p>
)
}
return (
<ChartTable
aria-label={caption}
columns={columns}
rows={rows.map((row) => ({
app: <span className="font-medium">{row.app}</span>,
target: (
<span className="font-mono text-xs text-muted-foreground">
{row.target}
</span>
),
status: <StatusBadge status={row.status} />,
latency: row.latency,
time: <span className="text-muted-foreground">{row.time}</span>,
}))}
/>
)
}
/**
* 概览仪表盘:统计卡片(含迷你走势)、响应耗时与错误预算、状态码分布与最慢接口、最近事件表。
* 全部用站内自研的数据展示组件拼成;图表区用容器查询(@container)排版,
* 主区域被右侧 aside 挤窄时会自动改为单列,而不是只看浏览器视口宽度。
*/
function DashboardOverview() {
const text = useAppShellLabels()
const [scope, setScope] = React.useState("all")
const rows =
scope === "all"
? events
: events.filter((event) =>
scope === "orders" ? event.app === "订单中心" : event.app === "支付网关",
)
const max = Math.max(...latencySeries)
const min = Math.min(...latencySeries)
const average = Math.round(
latencySeries.reduce((sum, value) => sum + value, 0) / latencySeries.length,
)
const latencyData = latencySeries.map((latency, index) => ({
hour: `${String(index).padStart(2, "0")}:00`,
latency,
}))
return (
<div
data-slot="app-shell-dashboard-overview"
className="flex min-w-0 flex-col"
>
<PageHeader
section={text.overviewSection}
title={text.overviewTitle}
description={text.overviewDescription}
actions={
<>
<Button variant="outline">
<DownloadIcon data-icon="inline-start" />
{text.exportAction}
</Button>
<Button>
<RefreshCwIcon data-icon="inline-start" />
{text.refreshAction}
</Button>
</>
}
/>
<PageTabs
tabs={scopes}
value={scope}
onValueChange={setScope}
trailing={dateRange}
/>
<PageContent className="@container">
<section
aria-label={text.keyMetrics}
className="grid gap-4 @min-[480px]:grid-cols-2 @min-[960px]:grid-cols-4"
>
{stats.map((stat) => (
<StatCard key={stat.id}>
<StatHeader>
<StatLabel>{stat.label}</StatLabel>
<StatIcon tone="primary">
<stat.icon />
</StatIcon>
</StatHeader>
<StatValue value={stat.value} unit={stat.unit} format={stat.format} />
<MiniChart
type="area"
className="h-10"
data={stat.trend.map((value, index) => ({
label: `${String(index * 2).padStart(2, "0")}:00`,
value,
}))}
seriesLabel={stat.label}
format={stat.format}
label={formatLabel(text.statTrendLabel, stat.label)}
/>
<StatTrend
value={stat.delta}
format={stat.deltaFormat}
invert={stat.invert}
>
{stat.hint}
</StatTrend>
</StatCard>
))}
</section>
<section
aria-label={text.latencyTrend}
className="grid gap-4 @3xl:grid-cols-3"
>
<ChartCard className="@3xl:col-span-2">
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>{text.latencyTrend}</ChartCardTitle>
<ChartCardDescription>{text.latencyTrendUnit}</ChartCardDescription>
</div>
<ChartMetrics>
<ChartMetric label={text.latencyPeak} value={`${max} ms`} />
<ChartMetric label={text.latencyLow} value={`${min} ms`} />
<ChartMetric label={text.latencyAverage} value={`${average} ms`} />
</ChartMetrics>
</ChartCardHeader>
<ChartCardContent className="flex-1">
<ChartSeries
type="area"
className="h-full min-h-44"
data={latencyData}
xKey="hour"
series={[
{ key: "latency", label: text.latencySeries, tone: "primary" },
]}
label={formatLabel(text.latencyChartLabel, { max, min })}
/>
</ChartCardContent>
</ChartCard>
<ErrorBudgetCard />
</section>
<section
aria-label={text.statusCodes}
className="grid gap-4 @3xl:grid-cols-3"
>
<StatusCodeCard />
<SlowestEndpointsCard className="@3xl:col-span-2" />
</section>
<ChartCard>
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>{text.recentEvents}</ChartCardTitle>
<ChartCardDescription>
{formatLabel(text.recentEventsCount, { count: rows.length })}
</ChartCardDescription>
</div>
</ChartCardHeader>
<ChartCardContent>
<EventsTable
rows={rows}
showLatency
caption={text.eventsCaption}
/>
</ChartCardContent>
</ChartCard>
</PageContent>
</div>
)
}
export { DashboardOverview, EventsTable, StatusBadge }
"use client"
import type { ReactNode } from "react"
import { formatStat, type DataTone } from "@/lib/data-display"
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
ChartSeries,
} from "@/components/ui/chart-card/chart-card"
import { MiniRing } from "@/components/ui/mini-chart/mini-chart"
import {
StatBadge,
StatCard,
StatHeader,
StatLabel,
StatMeta,
} from "@/components/ui/stat-card/stat-card"
import { formatLabel, useAppShellLabels } from "./config"
import { DistributionCard } from "./distribution-card"
import {
eventVolume,
failureReasons,
latencyBuckets,
nodeGroups,
probeVolume,
resolution,
sloTargets,
} from "./sample-data"
const percent = (value: number, digits = 0) =>
formatStat(value, { style: "percent", digits })
/** 图表卡片的标题区:标题 + 说明,右侧可放指标行。 */
function SummaryHeader({
title,
description,
children,
}: {
title: string
description: string
children?: ReactNode
}) {
return (
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>{title}</ChartCardTitle>
<ChartCardDescription>{description}</ChartCardDescription>
</div>
{children}
</ChartCardHeader>
)
}
/** 拨测记录:按拨测类型堆叠的拨测量 + 异常原因占比。 */
function RecordsSummary() {
const text = useAppShellLabels()
const total = probeVolume.reduce(
(sum, row) => sum + row.http + row.tcp + row.dns + row.ping,
0,
)
const failed = failureReasons.reduce((sum, item) => sum + item.value, 0)
return (
<section
data-slot="app-shell-records-summary"
aria-label={text.pageSummary}
className="grid gap-4 @3xl:grid-cols-3"
>
<ChartCard className="@3xl:col-span-2">
<SummaryHeader
title={text.probeVolume}
description={text.probeVolumeDescription}
>
<ChartMetrics>
<ChartMetric label={text.metricTotal} value={formatStat(total)} />
<ChartMetric
label={text.metricSuccessRate}
value={percent(((total - failed) / total) * 100, 1)}
/>
<ChartMetric label={text.metricFailed} value={failed} />
</ChartMetrics>
</SummaryHeader>
<ChartCardContent className="flex-1">
<ChartSeries
type="bar"
stacked
className="h-full min-h-52"
data={probeVolume}
xKey="hour"
series={[
{ key: "http", label: text.probeHttp, tone: "blue" },
{ key: "tcp", label: text.probeTcp, tone: "violet" },
{ key: "dns", label: text.probeDns, tone: "cyan" },
{ key: "ping", label: text.probePing, tone: "amber" },
]}
label={text.probeVolumeLabel}
/>
</ChartCardContent>
</ChartCard>
<DistributionCard
title={text.failureReasons}
description={text.failureReasonsDescription}
segments={failureReasons}
totalLabel={text.failureReasonsTotal}
ariaLabel={formatLabel(text.failureReasonsLabel, { total: failed })}
/>
</section>
)
}
/** 达标率越低颜色越重:不低于 95% 用绿色,否则琥珀。 */
function sloTone(value: number): DataTone {
return value >= 95 ? "green" : "amber"
}
/** 接口探测:耗时分布直方图 + 三档分位的 SLO 达标环。 */
function InterfacesSummary() {
const text = useAppShellLabels()
return (
<section
data-slot="app-shell-interfaces-summary"
aria-label={text.pageSummary}
className="grid gap-4 @3xl:grid-cols-3"
>
<ChartCard className="@3xl:col-span-2">
<SummaryHeader
title={text.latencyDistribution}
description={text.latencyDistributionDescription}
/>
<ChartCardContent className="flex-1">
<ChartSeries
type="bar"
className="h-full min-h-52"
data={latencyBuckets}
xKey="bucket"
series={[
{
key: "count",
label: text.latencyDistributionSeries,
tone: "blue",
},
]}
label={text.latencyDistributionLabel}
/>
</ChartCardContent>
</ChartCard>
<ChartCard>
<SummaryHeader
title={text.sloAttainment}
description={text.sloAttainmentDescription}
/>
<ChartCardContent className="flex flex-1 items-center justify-between gap-2">
{sloTargets.map((slo) => (
<div
key={slo.id}
className="flex flex-col items-center gap-3 text-center"
>
<MiniRing
value={slo.value}
size={64}
thickness={11}
tone={sloTone(slo.value)}
label={formatLabel(text.sloRingLabel, {
name: slo.label,
percent: percent(slo.value),
})}
/>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{slo.label}</span>
<span className="text-xs whitespace-nowrap text-muted-foreground">
{slo.target}
</span>
</div>
</div>
))}
</ChartCardContent>
</ChartCard>
</section>
)
}
/** 资源使用率越高颜色越重:85% 以上玫红、70% 以上琥珀,其余用传入的基础色。 */
function usageTone(value: number, base: DataTone): DataTone {
return value >= 85 ? "rose" : value >= 70 ? "amber" : base
}
/** 节点监控:每个区域一张卡片,两个进度环分别是 CPU 与内存使用率。 */
function NodesSummary() {
const text = useAppShellLabels()
return (
<section
data-slot="app-shell-nodes-summary"
aria-label={text.nodeResources}
className="grid gap-4 @min-[480px]:grid-cols-2 @min-[960px]:grid-cols-4"
>
{nodeGroups.map((node) => {
const healthy =
node.online === node.total && Math.max(node.cpu, node.memory) < 70
const rings = [
{ id: "cpu", name: text.nodeCpu, value: node.cpu, base: "blue" },
{
id: "memory",
name: text.nodeMemory,
value: node.memory,
base: "violet",
},
] as const
return (
<StatCard key={node.id}>
<StatHeader>
<StatLabel>{node.name}</StatLabel>
<StatBadge status={healthy ? "ok" : "watch"}>
{healthy ? text.stateHealthy : text.stateWarning}
</StatBadge>
</StatHeader>
<div className="flex items-start justify-around gap-3 py-2">
{rings.map((ring) => (
<div
key={ring.id}
className="flex flex-col items-center gap-2 text-xs text-muted-foreground"
>
<MiniRing
value={ring.value}
size={68}
thickness={11}
tone={usageTone(ring.value, ring.base)}
label={formatLabel(text.nodeRingLabel, {
name: node.name,
metric: ring.name,
percent: percent(ring.value),
})}
>
<span className="text-xs font-semibold text-foreground">
{percent(ring.value)}
</span>
</MiniRing>
{ring.name}
</div>
))}
</div>
<StatMeta>
<span>
{formatLabel(text.nodeOnline, {
online: node.online,
total: node.total,
})}
</span>
</StatMeta>
</StatCard>
)
})}
</section>
)
}
/** 事件与异常:按级别堆叠的事件趋势 + 异常事件的处理进度。 */
function EventsSummary() {
const text = useAppShellLabels()
const errors = eventVolume.reduce((sum, row) => sum + row.error, 0)
const warnings = eventVolume.reduce((sum, row) => sum + row.warning, 0)
const recoveredRatio = (resolution.recovered / errors) * 100
return (
<section
data-slot="app-shell-events-summary"
aria-label={text.pageSummary}
className="grid gap-4 @3xl:grid-cols-3"
>
<ChartCard className="@3xl:col-span-2">
<SummaryHeader
title={text.eventsTrend}
description={text.eventsTrendDescription}
>
<ChartMetrics>
<ChartMetric label={text.eventsTotal} value={errors + warnings} />
<ChartMetric label={text.eventError} value={errors} tone="rose" />
<ChartMetric
label={text.eventWarning}
value={warnings}
tone="amber"
/>
</ChartMetrics>
</SummaryHeader>
<ChartCardContent className="flex-1">
<ChartSeries
type="bar"
stacked
className="h-full min-h-52"
data={eventVolume}
xKey="hour"
series={[
{ key: "error", label: text.eventError, tone: "rose" },
{ key: "warning", label: text.eventWarning, tone: "amber" },
]}
label={text.eventsTrendLabel}
/>
</ChartCardContent>
</ChartCard>
<ChartCard>
<SummaryHeader
title={text.resolution}
description={text.resolutionDescription}
/>
<ChartCardContent className="flex flex-1 flex-col items-center justify-center gap-6">
<MiniRing
value={recoveredRatio}
size={128}
thickness={9}
tone="green"
label={formatLabel(text.resolutionLabel, {
percent: percent(recoveredRatio),
})}
>
<span className="text-2xl font-semibold">
{percent(recoveredRatio)}
</span>
<span className="text-[11px] text-muted-foreground">
{text.resolutionRecovered}
</span>
</MiniRing>
<ChartMetrics>
<ChartMetric
label={text.resolutionRecovered}
value={resolution.recovered}
tone="green"
/>
<ChartMetric
label={text.resolutionInProgress}
value={resolution.inProgress}
tone="amber"
/>
<ChartMetric
label={text.resolutionPending}
value={resolution.pending}
tone="rose"
/>
</ChartMetrics>
</ChartCardContent>
</ChartCard>
</section>
)
}
/** 按导航项 id 取对应页面的数据摘要;没有定制摘要的页面返回 null。 */
function PageSummary({ id }: { id: string }) {
switch (id) {
case "records":
return <RecordsSummary />
case "interfaces":
return <InterfacesSummary />
case "nodes":
return <NodesSummary />
case "events":
return <EventsSummary />
default:
return null
}
}
export {
EventsSummary,
InterfacesSummary,
NodesSummary,
PageSummary,
RecordsSummary,
}
"use client"
import { formatStat, type DataTone } from "@/lib/data-display"
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartLegendGrid,
} from "@/components/ui/chart-card/chart-card"
import { MiniDonut } from "@/components/ui/mini-chart/mini-chart"
type DistributionSegment = {
label: string
value: number
tone: DataTone
}
type DistributionCardProps = {
title: string
description: string
segments: readonly DistributionSegment[]
/** 环中央合计数字下方的小字,如「总请求」 */
totalLabel: string
/** 读屏名称,描述这张环形图表示什么 */
ariaLabel: string
className?: string
}
/** 占比卡片:环形图中央是合计,下方的图例格给出各分类的占比。概览的状态码分布与拨测的异常原因共用。 */
function DistributionCard({
title,
description,
segments,
totalLabel,
ariaLabel,
className,
}: DistributionCardProps) {
const total = segments.reduce((sum, item) => sum + item.value, 0)
return (
<ChartCard data-slot="app-shell-distribution-card" className={className}>
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>{title}</ChartCardTitle>
<ChartCardDescription>{description}</ChartCardDescription>
</div>
</ChartCardHeader>
<ChartCardContent className="flex flex-1 flex-col items-center justify-center gap-6">
<MiniDonut
size={136}
thickness={26}
segments={segments.map(({ label, value, tone }) => ({
label,
value,
tone,
}))}
label={ariaLabel}
>
<span className="text-lg font-semibold">{formatStat(total)}</span>
<span className="text-[11px] text-muted-foreground">{totalLabel}</span>
</MiniDonut>
<ChartLegendGrid
className="w-full"
items={segments.map(({ label, value, tone }) => ({
label,
tone,
value: formatStat((value / total) * 100, {
style: "percent",
digits: 1,
}),
}))}
/>
</ChartCardContent>
</ChartCard>
)
}
export { DistributionCard }
export type { DistributionCardProps, DistributionSegment }
"use client"
import { formatStat } from "@/lib/data-display"
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardFooter,
ChartCardHeader,
ChartCardTitle,
ChartGauge,
ChartMetric,
ChartMetrics,
ChartRanking,
} from "@/components/ui/chart-card/chart-card"
import { formatLabel, useAppShellLabels } from "./config"
import { DistributionCard } from "./distribution-card"
import { errorBudget, slowestEndpoints, statusCodes } from "./sample-data"
type CardProps = { className?: string }
/** 百分比文字:可用率这类接近 100 的数值保留两位小数 */
const percent = (value: number, digits = 0) =>
formatStat(value, { style: "percent", digits })
/** 错误预算:刻度仪表显示 30 天窗口里还剩多少,底部写明已消耗的分钟数。 */
function ErrorBudgetCard({ className }: CardProps) {
const text = useAppShellLabels()
const remaining = Math.round(
(1 - errorBudget.usedMinutes / errorBudget.totalMinutes) * 100,
)
return (
<ChartCard data-slot="app-shell-error-budget" className={className}>
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>{text.errorBudget}</ChartCardTitle>
<ChartCardDescription>
{formatLabel(text.errorBudgetDescription, {
target: percent(errorBudget.target, 2),
})}
</ChartCardDescription>
</div>
</ChartCardHeader>
<ChartCardContent className="flex flex-1 flex-col items-center justify-center gap-5">
<ChartGauge
value={remaining}
tone="green"
caption={text.errorBudgetRemaining}
className="size-40"
label={formatLabel(text.errorBudgetLabel, {
percent: percent(remaining),
})}
/>
<ChartMetrics>
<ChartMetric
label={text.errorBudgetTarget}
value={percent(errorBudget.target, 2)}
/>
<ChartMetric
label={text.errorBudgetCurrent}
value={percent(errorBudget.current, 2)}
tone="green"
/>
</ChartMetrics>
</ChartCardContent>
<ChartCardFooter>
{formatLabel(text.errorBudgetUsed, {
used: errorBudget.usedMinutes,
total: errorBudget.totalMinutes,
})}
</ChartCardFooter>
</ChartCard>
)
}
/** 状态码分布:环形图中央放总请求数,旁边的图例格给出各类占比。 */
function StatusCodeCard({ className }: CardProps) {
const text = useAppShellLabels()
const total = formatStat(statusCodes.reduce((sum, item) => sum + item.value, 0))
return (
<DistributionCard
className={className}
title={text.statusCodes}
description={text.statusCodesDescription}
segments={statusCodes}
totalLabel={text.statusCodesTotal}
ariaLabel={formatLabel(text.statusCodesLabel, { total })}
/>
)
}
/** 越慢颜色越重:超过 1 s 用玫红、超过 500 ms 用琥珀,其余用蓝色。 */
function slownessTone(latency: number) {
return latency > 1000 ? "rose" : latency > 500 ? "amber" : "blue"
}
/** 最慢接口排行:横向排名条,条末是 P95 耗时。 */
function SlowestEndpointsCard({ className }: CardProps) {
const text = useAppShellLabels()
return (
<ChartCard data-slot="app-shell-slowest-endpoints" className={className}>
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>{text.slowestEndpoints}</ChartCardTitle>
<ChartCardDescription>
{text.slowestEndpointsDescription}
</ChartCardDescription>
</div>
</ChartCardHeader>
<ChartCardContent className="flex flex-1 flex-col justify-center">
<ChartRanking
items={slowestEndpoints.map(({ label, value }) => ({
label,
value,
tone: slownessTone(value),
}))}
// 默认视口 lg 起图例并排;卡片只占 2/3 宽,改为图例在下方,主区域够宽(@5xl)再并排
className="lg:grid-cols-1 @5xl:grid-cols-2"
format={{ style: "number", compact: false, digits: 0 }}
label={text.slowestEndpointsLabel}
/>
</ChartCardContent>
</ChartCard>
)
}
export { ErrorBudgetCard, SlowestEndpointsCard, StatusCodeCard }
"use client"
import { PlusIcon } from "lucide-react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
ChartCard,
ChartCardContent,
} from "@/components/ui/chart-card/chart-card"
import { formatLabel, useAppShellLabels } from "./config"
import { EventsTable } from "./dashboard-overview"
import { PageFrame } from "./page-header"
import { PageToolbar } from "./page-toolbar"
import { events } from "./sample-data"
const PAGE_SIZE = 6
/**
* 通用列表页示例:标题栏 + 工具栏(搜索、翻页)+ 可选的数据摘要 + 表格。其余导航项在演示里都显示它。
* summary 插槽放在表格上方,通常是该页自己的指标与图表(见 page-summaries.tsx)。
*/
function ListPage({
title,
description,
section,
summary,
}: {
title: string
description?: string
section?: string
summary?: React.ReactNode
}) {
const text = useAppShellLabels()
const [search, setSearch] = React.useState("")
const [page, setPage] = React.useState(1)
const keyword = search.trim().toLowerCase()
const filtered = events.filter(
(row) =>
!keyword ||
row.app.toLowerCase().includes(keyword) ||
row.target.toLowerCase().includes(keyword),
)
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const current = Math.min(page, totalPages)
const rows = filtered.slice((current - 1) * PAGE_SIZE, current * PAGE_SIZE)
return (
<PageFrame
data-slot="app-shell-list-page"
section={section}
title={title}
description={description}
actions={
<Button>
<PlusIcon data-icon="inline-start" />
{text.createAction}
</Button>
}
toolbar={
<PageToolbar
total={filtered.length}
page={current}
pageSize={PAGE_SIZE}
onPageChange={setPage}
search={search}
onSearchChange={(value) => {
setSearch(value)
setPage(1)
}}
searchLabel={text.searchEvents}
/>
}
>
{/* 摘要按所在区域的宽度排版:右侧挂着 aside 时主区域变窄,图表会自动改为单列 */}
{summary ? (
<div data-slot="app-shell-list-summary" className="@container empty:hidden">
{summary}
</div>
) : null}
<ChartCard>
<ChartCardContent>
<EventsTable
rows={rows}
caption={formatLabel(text.listCaption, { title })}
/>
</ChartCardContent>
</ChartCard>
</PageFrame>
)
}
export { ListPage }
/** 演示用假数据:替换成你自己的接口数据即可。 */
import {
ActivityIcon,
BellRingIcon,
GlobeIcon,
LayoutGridIcon,
RadarIcon,
TimerIcon,
type LucideIcon,
} from "lucide-react"
import type { StatFormatOptions } from "@/lib/data-display"
type EventStatus = "ok" | "warning" | "error"
type EventRow = {
id: string
app: string
target: string
status: EventStatus
latency: string
time: string
}
const events: EventRow[] = [
{ id: "e-1042", app: "订单中心", target: "POST /api/orders", status: "ok", latency: "128 ms", time: "14:32:05" },
{ id: "e-1041", app: "用户中心", target: "GET /api/profile", status: "ok", latency: "86 ms", time: "14:31:48" },
{ id: "e-1040", app: "支付网关", target: "POST /api/pay/notify", status: "error", latency: "超时", time: "14:30:12" },
{ id: "e-1039", app: "商品服务", target: "GET /api/items?page=1", status: "warning", latency: "1 420 ms", time: "14:28:57" },
{ id: "e-1038", app: "订单中心", target: "GET /api/orders/9021", status: "ok", latency: "97 ms", time: "14:27:33" },
{ id: "e-1037", app: "消息推送", target: "POST /api/push", status: "ok", latency: "212 ms", time: "14:26:10" },
{ id: "e-1036", app: "用户中心", target: "POST /api/login", status: "warning", latency: "960 ms", time: "14:24:41" },
{ id: "e-1035", app: "支付网关", target: "GET /api/pay/status", status: "ok", latency: "74 ms", time: "14:23:02" },
{ id: "e-1034", app: "商品服务", target: "GET /api/items/301", status: "ok", latency: "103 ms", time: "14:21:50" },
{ id: "e-1033", app: "消息推送", target: "GET /api/push/queue", status: "error", latency: "连接被拒绝", time: "14:19:26" },
{ id: "e-1032", app: "订单中心", target: "PUT /api/orders/9018", status: "ok", latency: "141 ms", time: "14:17:09" },
{ id: "e-1031", app: "用户中心", target: "GET /api/me", status: "ok", latency: "69 ms", time: "14:15:44" },
]
type StatItem = {
id: string
label: string
value: number
unit?: string
/** 大号数字的格式 */
format?: StatFormatOptions
/** 较上一周期的变化,单位是百分点,或 deltaFormat 指定的其他口径 */
delta: number
deltaFormat?: StatFormatOptions
/** 越低越好的指标(耗时、告警数):下降用「好」的颜色 */
invert?: boolean
hint: string
/** 近 24 小时每 2 小时一个点,共 12 个,用来画迷你走势 */
trend: number[]
icon: LucideIcon
}
const stats: StatItem[] = [
{
id: "availability",
label: "可用率",
value: 99.96,
unit: "%",
format: { compact: false, digits: 2 },
delta: 0.02,
deltaFormat: { digits: 2 },
hint: "近 24 小时",
trend: [99.91, 99.93, 99.9, 99.94, 99.95, 99.92, 99.94, 99.96, 99.95, 99.97, 99.96, 99.96],
icon: ActivityIcon,
},
{
id: "probes",
label: "拨测次数",
value: 128640,
format: { compact: false },
delta: 4.1,
hint: "较昨日",
trend: [8200, 8900, 9400, 10100, 10800, 11600, 12100, 11800, 12400, 13200, 13900, 14200],
icon: RadarIcon,
},
{
id: "latency",
label: "平均响应",
value: 132,
unit: "ms",
delta: -8,
deltaFormat: { style: "number", digits: 0 },
invert: true,
hint: "ms · P50",
trend: [141, 138, 145, 150, 148, 139, 142, 136, 134, 133, 131, 132],
icon: TimerIcon,
},
{
id: "alerts",
label: "未恢复告警",
value: 3,
delta: 1,
deltaFormat: { style: "number", digits: 0 },
invert: true,
hint: "需要处理",
trend: [1, 1, 2, 1, 1, 2, 2, 3, 2, 2, 3, 3],
icon: BellRingIcon,
},
]
/** 24 个小时的响应耗时(ms),用来画简易柱状图 */
const latencySeries = [
118, 124, 109, 131, 126, 140, 152, 148, 135, 129, 122, 118, 125, 133, 141,
160, 172, 149, 138, 130, 127, 121, 116, 112,
]
/** 概览页标签条:value 与 events 的 app 字段对应,替换数据时一起改 */
const scopes = [
{ value: "all", label: "全局总览", icon: GlobeIcon },
{ value: "orders", label: "订单中心", icon: LayoutGridIcon },
{ value: "payments", label: "支付网关", icon: LayoutGridIcon },
]
/** 概览页标签条右侧的时间范围说明 */
const dateRange = "2025-06-01 ~ 2025-06-02"
const services = [
{ id: "probe", label: "拨测服务", state: "healthy" as const, detail: "全部节点在线" },
{ id: "worker", label: "任务调度", state: "warning" as const, detail: "队列积压 1 200 条,正在消化" },
{ id: "notify", label: "通知通道", state: "healthy" as const, detail: "站内信、邮件均可用" },
]
/** 每 2 小时一档的横轴标签,共 12 档,概览以外的页面摘要共用 */
const bucketHours = Array.from(
{ length: 12 },
(_, index) => `${String(index * 2).padStart(2, "0")}:00`,
)
/** 状态码分布:近 24 小时 128 640 次请求;tone 同时用于环形图与图例 */
const statusCodes = [
{ id: "2xx", label: "2xx 成功", value: 118420, tone: "green" },
{ id: "3xx", label: "3xx 重定向", value: 6310, tone: "blue" },
{ id: "4xx", label: "4xx 客户端", value: 2980, tone: "amber" },
{ id: "5xx", label: "5xx 服务端", value: 930, tone: "rose" },
] as const
/** 错误预算(30 天窗口,SLA 99.95% 对应 21.6 分钟不可用时间) */
const errorBudget = {
target: 99.95,
current: 99.96,
totalMinutes: 21.6,
usedMinutes: 8.2,
}
/** 最慢接口:近 24 小时 P95 耗时(ms),已按从慢到快排序 */
const slowestEndpoints = [
{ label: "POST /api/pay/notify", value: 1840 },
{ label: "GET /api/items?page=1", value: 1420 },
{ label: "POST /api/login", value: 960 },
{ label: "GET /api/push/queue", value: 780 },
{ label: "POST /api/orders", value: 540 },
{ label: "GET /api/orders/9021", value: 310 },
]
/** 拨测记录:每 2 小时一档、按拨测类型的次数,合计 128 640 次,与概览的拨测次数一致 */
const probeVolume = [
[4787, 1390, 927, 618],
[5196, 1509, 1006, 670],
[5489, 1593, 1062, 708],
[5897, 1712, 1141, 761],
[6305, 1831, 1221, 814],
[6773, 1966, 1311, 874],
[7065, 2051, 1367, 912],
[6890, 2000, 1333, 889],
[7240, 2102, 1401, 934],
[7707, 2238, 1492, 994],
[8116, 2356, 1571, 1047],
[8292, 2407, 1605, 1070],
].map(([http, tcp, dns, ping], index) => ({
hour: bucketHours[index],
http,
tcp,
dns,
ping,
}))
/** 拨测记录:失败与超时共 515 次,按原因拆分 */
const failureReasons = [
{ label: "连接超时", value: 236, tone: "rose" },
{ label: "状态码异常", value: 139, tone: "amber" },
{ label: "DNS 解析", value: 78, tone: "violet" },
{ label: "TLS 握手", value: 62, tone: "blue" },
] as const
/** 接口探测:近 24 小时各耗时区间的请求数,合计 128 640 */
const latencyBuckets = [
{ bucket: "<50", count: 18420 },
{ bucket: "50–100", count: 41260 },
{ bucket: "100–200", count: 39870 },
{ bucket: "200–500", count: 21540 },
{ bucket: "0.5–1 s", count: 5980 },
{ bucket: ">1 s", count: 1570 },
]
/** 接口探测:各分位耗时的 SLO 达标率(%) */
const sloTargets = [
{ id: "p50", label: "P50", target: "≤ 150 ms", value: 98 },
{ id: "p95", label: "P95", target: "≤ 800 ms", value: 94 },
{ id: "p99", label: "P99", target: "≤ 1.5 s", value: 91 },
]
/** 节点监控:按区域汇总的节点资源,cpu / memory 是使用率(%) */
const nodeGroups = [
{ id: "bj", name: "华北 · 北京", online: 12, total: 12, cpu: 38, memory: 52 },
{ id: "sh", name: "华东 · 上海", online: 16, total: 16, cpu: 46, memory: 61 },
{ id: "gz", name: "华南 · 广州", online: 8, total: 9, cpu: 71, memory: 78 },
{ id: "sg", name: "海外 · 新加坡", online: 6, total: 6, cpu: 29, memory: 44 },
]
/** 事件与异常:每 2 小时一档的事件数,按级别拆分 */
const eventVolume = [
[1, 4], [0, 3], [0, 2], [2, 5], [1, 4], [0, 3],
[1, 6], [3, 7], [2, 5], [1, 6], [4, 8], [3, 7],
].map(([error, warning], index) => ({
hour: bucketHours[index],
error,
warning,
}))
/** 事件与异常:异常事件的处理进度,三项相加为异常总数 18 */
const resolution = { recovered: 15, inProgress: 2, pending: 1 }
export {
bucketHours,
dateRange,
errorBudget,
eventVolume,
events,
failureReasons,
latencyBuckets,
latencySeries,
nodeGroups,
probeVolume,
resolution,
scopes,
services,
slowestEndpoints,
sloTargets,
statusCodes,
stats,
}
export type { EventRow, EventStatus, StatItem }
"use client"
import { createContext, createElement, useContext, type ReactNode } from "react"
/** 记住侧栏折叠状态的 localStorage 键(统一使用 travinsy: 前缀) */
const DEFAULT_STORAGE_KEY = "travinsy:app-shell:collapsed"
/**
* 侧栏折叠状态在水合前由内联脚本写到 AppShell 根元素的 `data-collapsed`,
* 所以所有随折叠变化的样式都用 `group-data-[collapsed]/app-shell:` 变体表达,
* 而不是依赖 React 状态——这样首帧就是正确的折叠状态。
* Tailwind 需要看到完整类名,所以下面写字面量而不是拼接。
*/
/**
* 一级导航的宽度类名,只在这里定义一次:
* app-shell.tsx 的侧栏与 app-header.tsx 的品牌区共用,顶栏才能和导航对齐。
*/
const NAV_WIDTH_CLASS = {
/** 侧栏:展开 200px,折叠 60px */
sidebar: "w-[200px] group-data-[collapsed]/app-shell:w-[60px]",
/** 顶栏品牌区:同侧栏;窄屏下始终按图标栏宽度 */
brand: "w-[200px] group-data-[collapsed]/app-shell:w-[60px] max-[800px]:w-[60px]",
} as const
/**
* 宽度变化时的过渡,和宽度放在一起,避免两处不一致。
* 只有 AppShell 根元素带 `data-animate`(用户点击折叠按钮之后)才播放,
* 首次渲染与水合时不会出现宽度动画。
*/
const NAV_WIDTH_TRANSITION =
"motion-safe:group-data-[animate]/app-shell:transition-[width] motion-safe:group-data-[animate]/app-shell:duration-200"
/**
* 控制台布局里所有用户可见的文案。
* 通过 AppShell 的 `labels` 覆盖其中任意几项;带占位符的文案用 formatLabel 填充。
* 导航数据里的文字不在这里,见 nav-config.ts;演示数据见 sample-data.ts。
*/
const DEFAULT_LABELS = {
skipToContent: "跳转到主内容",
mainContent: "主内容",
mainNavigation: "主导航",
mainNavigationDescription: "选择要访问的页面",
openNavigation: "打开导航",
collapseSidebar: "收起侧边栏",
expandSidebar: "展开侧边栏",
breadcrumb: "面包屑",
brandHome: "{name},回到首页",
workspaceNavigation: "{name}导航",
workspaceSidebar: "{name}侧栏",
toggleTheme: "切换亮暗模式",
userMenu: "用户菜单:{name}",
signOut: "退出登录",
pageToolbar: "列表控制",
totalUnit: "条",
previousPage: "上一页",
nextPage: "下一页",
search: "搜索",
serviceStatus: "服务状态",
serviceStatusAria: "查看服务状态:{name}",
checkedAt: "检查于 {name}",
stateHealthy: "可用",
stateWarning: "需关注",
stateUnavailable: "不可用",
stateUnknown: "未知",
// 概览仪表盘
overviewSection: "工作台",
overviewTitle: "概览",
overviewDescription: "近 24 小时的拨测质量与告警情况",
exportAction: "导出",
refreshAction: "刷新",
scopeSwitch: "数据范围",
keyMetrics: "关键指标",
statTrendLabel: "{name}近 24 小时走势",
latencyTrend: "响应耗时趋势",
latencyTrendUnit: "每小时 P50,单位 ms",
latencySeries: "P50 响应耗时",
latencyPeak: "峰值",
latencyLow: "谷值",
latencyAverage: "均值",
latencyChartLabel: "过去 24 小时响应耗时,最高 {max} 毫秒,最低 {min} 毫秒",
recentEvents: "最近事件",
recentEventsCount: "按时间倒序,共 {count} 条",
eventsCaption: "最近拨测事件",
// 概览的补充图表
errorBudget: "错误预算",
errorBudgetDescription: "30 天窗口,SLA 目标 {target}",
errorBudgetRemaining: "预算剩余",
errorBudgetLabel: "30 天错误预算剩余 {percent}",
errorBudgetTarget: "SLA 目标",
errorBudgetCurrent: "当前可用率",
errorBudgetUsed: "已消耗 {used} / {total} 分钟",
statusCodes: "状态码分布",
statusCodesDescription: "近 24 小时请求按响应状态码归类",
statusCodesTotal: "总请求",
statusCodesLabel: "近 24 小时状态码分布,共 {total} 次请求",
slowestEndpoints: "最慢接口",
slowestEndpointsDescription: "近 24 小时 P95 响应耗时,单位 ms",
slowestEndpointsLabel: "最慢接口 P95 响应耗时排名",
// 各导航页的数据摘要
pageSummary: "数据摘要",
metricTotal: "总拨测",
metricSuccessRate: "成功率",
metricFailed: "失败与超时",
probeVolume: "拨测量",
probeVolumeDescription: "今日每 2 小时一档,按拨测类型堆叠",
probeVolumeLabel: "今日拨测量按拨测类型堆叠的柱图",
probeHttp: "HTTP(S)",
probeTcp: "TCP",
probeDns: "DNS",
probePing: "Ping",
failureReasons: "异常原因",
failureReasonsDescription: "失败与超时的原因占比",
failureReasonsTotal: "异常拨测",
failureReasonsLabel: "异常拨测原因占比,共 {total} 次",
latencyDistribution: "耗时分布",
latencyDistributionDescription: "近 24 小时各耗时区间的请求数",
latencyDistributionSeries: "请求数",
latencyDistributionLabel: "近 24 小时响应耗时分布柱图",
sloAttainment: "SLO 达标率",
sloAttainmentDescription: "按接口统计,达标的占比",
sloRingLabel: "{name} 耗时 SLO 达标 {percent}",
nodeResources: "节点资源概览",
nodeCpu: "CPU",
nodeMemory: "内存",
nodeOnline: "在线节点 {online} / {total}",
nodeRingLabel: "{name}{metric}使用率 {percent}",
eventsTrend: "事件趋势",
eventsTrendDescription: "今日每 2 小时一档,按级别堆叠",
eventsTrendLabel: "今日事件数按级别堆叠的柱图",
eventsTotal: "事件总数",
resolution: "处理进度",
resolutionDescription: "异常事件的恢复情况",
resolutionRecovered: "已恢复",
resolutionInProgress: "处理中",
resolutionPending: "待认领",
resolutionLabel: "异常事件已恢复 {percent}",
// 列表与表格
createAction: "新建",
searchEvents: "搜索应用或目标",
listCaption: "{title}列表",
emptyRows: "没有匹配的记录",
columnApp: "应用",
columnTarget: "目标",
columnStatus: "状态",
columnLatency: "耗时",
columnTime: "时间",
// 事件状态
eventOk: "正常",
eventWarning: "需关注",
eventError: "异常",
} as const
type AppShellLabels = { [Key in keyof typeof DEFAULT_LABELS]: string }
/** 合并用户传入的文案与默认文案。 */
function resolveLabels(labels?: Partial<AppShellLabels>): AppShellLabels {
return { ...DEFAULT_LABELS, ...labels }
}
/**
* 把文案里的占位符替换成实际值。
* 传字符串时等价于替换 `{name}`(保持早期写法可用);传对象可一次替换多个,如 `{ count: 3 }`。
*/
function formatLabel(
template: string,
values: string | Record<string, string | number>,
): string {
if (typeof values === "string") return template.replace("{name}", values)
return Object.entries(values).reduce(
(text, [key, value]) => text.replaceAll(`{${key}}`, String(value)),
template,
)
}
/** AppShell 向下提供的文案,让主区域里的页面组件也能拿到同一套覆盖值。 */
const AppShellLabelsContext = createContext<Partial<AppShellLabels> | undefined>(
undefined,
)
function AppShellLabelsProvider({
labels,
children,
}: {
labels?: Partial<AppShellLabels>
children: ReactNode
}) {
// 本文件是 .ts(不含 JSX),用 createElement 渲染 Provider
return createElement(AppShellLabelsContext.Provider, { value: labels }, children)
}
/**
* 读取当前文案:组件自己的 labels > AppShell 提供的 labels > DEFAULT_LABELS。
* 页面组件(PageHeader、PageToolbar 等)放在 AppShell 里时会自动继承 AppShell 的文案。
*/
function useAppShellLabels(labels?: Partial<AppShellLabels>): AppShellLabels {
const inherited = useContext(AppShellLabelsContext)
return resolveLabels({ ...inherited, ...labels })
}
export {
AppShellLabelsProvider,
DEFAULT_LABELS,
DEFAULT_STORAGE_KEY,
NAV_WIDTH_CLASS,
NAV_WIDTH_TRANSITION,
formatLabel,
resolveLabels,
useAppShellLabels,
}
export type { AppShellLabels }
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }
AppShell 提炼自我的一个企业监控平台项目的整体布局,去掉了接口请求、权限过滤和登录逻辑,只留下布局与交互本身:
- 固定顶栏:左侧品牌区宽度与导航对齐,右侧依次是
headerActions、亮暗模式切换、用户菜单;中间是自动生成的面包屑。 - 一级导航:按分组排列,当前页高亮;可折叠为图标栏(名称出现在 Tooltip 里),折叠状态记在
localStorage,刷新后首帧就是上次的状态,不会闪烁。 - 二级工作区侧栏:一级导航里的「模块」被选中时,右侧多出一栏,列出该模块下的页面。
- 独立滚动的主区域:顶栏和侧栏固定,只有
main滚动,切换页面自动回到顶部。 - 右侧
aside插槽:与main并排,高度占满顶栏以下的区域,用来放 AI 助手面板等。 - 窄屏适配:宽度小于 768px 时,一级导航变成左侧 Sheet 抽屉,二级导航变成顶栏下方的横向标签条。
- 页面侧附带
PageHeader、PageTabs、PageToolbar、PageFrame,对应原项目的页面框架、范围切换和列表工具栏。 - 演示页直接用站内自研的数据展示组件拼成:
- 颜色全部来自主题变量,切换颜色主题和亮暗模式后自动跟随,预览里可以直接试。
预览里没有真实路由,所以演示用 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,本站的 统计卡片、迷你图、图表卡片 与 分段选择(概览页与范围切换用到),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
├── 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) 下的所有页面都会套上这层壳:
"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;脚本被拦截时退化为水合后再恢复折叠状态。
页面框架
主区域里的页面可以用 page-header.tsx 和 page-toolbar.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
导航数据:一级导航 + 二级(工作区)侧栏,见 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? | 范围切换条(应用、环境等),用分段选择实现,单选语义 |
PageToolbar | total page pageSize onPageChange search? onSearchChange? filters? 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"条"翻页按钮
类型string"上一页"翻页按钮
类型string"下一页"搜索框默认名称
类型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"{title}列表"空结果提示
类型string"没有匹配的记录"表头
类型string"应用"表头
类型string"目标"表头
类型string"状态"表头
类型string"耗时"表头
类型string"时间"事件状态徽标文字
类型string"正常"事件状态徽标文字
类型string"需关注"事件状态徽标文字
类型string"异常"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 阈值显示两列 / 四列,其余为单列;图表沿用容器查询。