feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling

- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -0,0 +1,211 @@
"use client";
import { Suspense, type ReactNode } from "react";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { useErrorReport } from "@edu/hooks";
import { cn } from "@/shared/lib/utils";
/**
* DashboardSection - 仪表盘分区包装器(对齐 CICD dashboard-section.tsx
*
* 三件套组合SectionErrorBoundary + Suspense + 5 种骨架变体
*
* 职责:
* 1. 隔离分区渲染错误(不影响其他分区)
* 2. 流式渲染Suspense 边界显示骨架屏,数据到达后替换
* 3. a11y传入 ariaLabel 时渲染 role="region" tabIndex={0}
*
* 5 种骨架变体:
* - stats统计卡片骨架大数字 + 标签)
* - card通用卡片骨架标题 + 内容块)
* - chart图表骨架坐标轴 + 柱状)
* - table表格骨架表头 + 多行)
* - list列表骨架多行
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L2 区块级)
*
* @example
* <DashboardSection title="今日课程" variant="table">
* <ScheduleList />
* </DashboardSection>
*/
export type DashboardSectionVariant =
"stats" | "card" | "chart" | "table" | "list";
export interface DashboardSectionProps {
/** 分区标题(显示在 CardHeader */
title?: string;
/** 分区描述(显示在 CardHeader */
description?: string;
/** 子节点(分区内容) */
children: ReactNode;
/** 骨架变体(默认 card */
variant?: DashboardSectionVariant;
/** a11y 标签(传入时渲染 role="region" tabIndex={0} */
ariaLabel?: string;
/** 右侧操作区(如"查看全部"链接) */
actions?: ReactNode;
/** 自定义类名 */
className?: string;
}
/**
* 5 种骨架变体实现
*/
export function DashboardSectionSkeleton({
variant = "card",
className,
}: {
variant?: DashboardSectionVariant;
className?: string;
}): ReactNode {
if (variant === "table") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/4" />
</CardHeader>
<CardContent className="space-y-2">
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
);
}
if (variant === "list") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/4" />
</CardHeader>
<CardContent className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
);
}
if (variant === "chart") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/3" />
</CardHeader>
<CardContent>
<div className="flex h-48 items-end gap-2">
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
<Skeleton
key={i}
className="flex-1 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</CardContent>
</Card>
);
}
if (variant === "stats") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/3" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
// card默认
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/3" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
);
}
/**
* DashboardSection - 仪表盘分区ErrorBoundary + Suspense + Skeleton
*/
export function DashboardSection({
title,
description,
children,
variant = "card",
ariaLabel,
actions,
className,
}: DashboardSectionProps): ReactNode {
const reportError = useErrorReport();
const sectionProps = ariaLabel
? { role: "region" as const, tabIndex: 0, "aria-label": ariaLabel }
: {};
return (
<section
{...sectionProps}
className={cn(
ariaLabel &&
"rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<SectionErrorBoundary
title={title}
onError={(error) => {
void reportError(error, {
level: "error",
context: { section: title },
});
}}
>
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
{(title || actions) && (
<div className="mb-4 flex items-center justify-between">
<div>
{title && (
<h2 className="text-lg font-semibold tracking-tight">
{title}
</h2>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
)}
{children}
</Suspense>
</SectionErrorBoundary>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { PageHeader } from "@/shared/components/ui/page-header";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { cn } from "@/shared/lib/utils";
/**
* DashboardShell - 仪表盘外壳(对齐 CICD dashboard-shell.tsx
*
* 极简结构PageHeader + StatsGrid可选+ children
* - stats 为空数组时不渲染统计区(适配无统计指标的页面)
* - children 是页面主体内容
*
* @example
* <DashboardShell
* title="教师仪表盘"
* description="今日教学概览"
* stats={<StatCard title="班级" value={6} />}
* actions={<Button>导出</Button>}
* >
* <DashboardSection title="今日课程">
* <ScheduleList />
* </DashboardSection>
* </DashboardShell>
*/
export interface DashboardShellProps {
/** 页面标题 */
title: string;
/** 页面描述 */
description?: string;
/** 标题前图标 */
icon?: ReactNode;
/** 右侧操作区 */
actions?: ReactNode;
/** 统计卡片组(传入 StatsGrid 或多个 StatCard */
stats?: ReactNode;
/** 主体内容 */
children: ReactNode;
/** 自定义类名 */
className?: string;
}
export function DashboardShell({
title,
description,
icon,
actions,
stats,
children,
className,
}: DashboardShellProps): ReactNode {
return (
<div className={cn("space-y-6 p-6", className)}>
<PageHeader
title={title}
description={description}
icon={icon}
actions={actions}
/>
{stats && <StatsGrid>{stats}</StatsGrid>}
<div className="space-y-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useState, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Separator } from "@/shared/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/lib/utils";
import {
SIDEBAR_WIDTH_COLLAPSED,
SIDEBAR_WIDTH_EXPANDED,
useSidebar,
} from "./sidebar-provider";
/**
* AppSidebar - 侧边栏实现(对齐 CICD app-sidebar.tsx
*
* 关键设计:
* - 桌面端:<aside> + transition-[width]w-64 ↔ w-16
* - 折叠态:仅图标 + Tooltiphover 显示标题sr-only 标签保证 a11y
* - 展开态Collapsible 子菜单defaultOpen={isActive} 自动展开当前路由所在组)
* - 导航项按权限过滤hasPermission(item.permission)
*
* 注意本组件是基础组件库的一部分portal-shell 的 LayoutManager 在 P1 阶段
* 重构时将使用此组件替换现有 5 种布局模板中的侧边栏部分。
*
* 关联portal-shell README v2.0 §5.3 布局组件
*/
export interface NavItem {
/** 显示名称 */
title: string;
/** 跳转链接 */
href?: string;
/** 图标lucide-react 图标组件) */
icon?: React.ComponentType<{ className?: string }>;
/** 所需权限点(无权限不显示) */
permission?: string;
/** 子菜单 */
children?: NavItem[];
}
export interface AppSidebarProps {
/** 导航配置(按角色分组) */
items: NavItem[];
/** 权限检查函数(从 usePermission().hasPermission 注入) */
hasPermission?: (perm: string) => boolean;
/** 侧边栏底部内容(如用户信息、版本号) */
footer?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function AppSidebar({
items,
hasPermission,
footer,
className,
}: AppSidebarProps): ReactNode {
const { expanded } = useSidebar();
const pathname = usePathname();
// 权限过滤
const visibleItems = items.filter(
(item) => !item.permission || hasPermission?.(item.permission) !== false,
);
return (
<TooltipProvider delayDuration={200}>
<aside
className={cn(
"flex h-screen flex-col border-r bg-card transition-[width] duration-200",
expanded ? SIDEBAR_WIDTH_EXPANDED : SIDEBAR_WIDTH_COLLAPSED,
className,
)}
>
<nav className="flex-1 overflow-y-auto p-2">
<ul className="space-y-1">
{visibleItems.map((item) => (
<li key={item.title}>
<NavMenuItem
item={item}
expanded={expanded}
pathname={pathname}
hasPermission={hasPermission}
/>
</li>
))}
</ul>
</nav>
{footer && (
<>
<Separator />
<div className="p-2">{footer}</div>
</>
)}
</aside>
</TooltipProvider>
);
}
function NavMenuItem({
item,
expanded,
pathname,
hasPermission,
}: {
item: NavItem;
expanded: boolean;
pathname: string;
hasPermission?: (perm: string) => boolean;
}): ReactNode {
const isActive = item.href === pathname;
const visibleChildren = item.children?.filter(
(c) => !c.permission || hasPermission?.(c.permission) !== false,
);
// 折叠态:仅图标 + Tooltip
if (!expanded) {
const Icon = item.icon;
if (!Icon) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant={isActive ? "secondary" : "ghost"}
size="icon"
className="w-full"
>
<Link href={item.href ?? "#"}>
<Icon className="size-4" />
<span className="sr-only">{item.title}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
);
}
// 展开态 + 无子菜单
if (!visibleChildren?.length) {
const Icon = item.icon;
return (
<Button
asChild
variant={isActive ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start"
>
<Link href={item.href ?? "#"}>
{Icon && <Icon className="size-4" />}
<span>{item.title}</span>
</Link>
</Button>
);
}
// 展开态 + 有子菜单Collapsible
return (
<CollapsibleNavItem
item={item}
pathname={pathname}
hasPermission={hasPermission}
/>
);
}
function CollapsibleNavItem({
item,
pathname,
hasPermission,
}: {
item: NavItem;
pathname: string;
hasPermission?: (perm: string) => boolean;
}): ReactNode {
const visibleChildren =
item.children?.filter(
(c) => !c.permission || hasPermission?.(c.permission) !== false,
) ?? [];
const hasActiveChild = visibleChildren.some((c) => c.href === pathname);
const [open, setOpen] = useState(hasActiveChild);
const Icon = item.icon;
return (
<div>
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
onClick={() => setOpen((v) => !v)}
>
<span className="flex items-center gap-2">
{Icon && <Icon className="size-4" />}
<span>{item.title}</span>
</span>
{open ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
{open && (
<ul className="ml-4 mt-1 space-y-1 border-l pl-2">
{visibleChildren.map((child) => {
const isActive = child.href === pathname;
const ChildIcon = child.icon;
return (
<li key={child.title}>
<Button
asChild
variant={isActive ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start"
>
<Link href={child.href ?? "#"}>
{ChildIcon && <ChildIcon className="size-4" />}
<span>{child.title}</span>
</Link>
</Button>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { cn } from "@/shared/lib/utils";
/**
* SidebarProvider - 侧边栏状态容器(对齐 CICD sidebar-provider.tsx
*
* 职责:
* - 管理桌面端折叠状态expanded: w-64 ↔ w-16
* - 管理移动端 Sheet 开合openMobile
* - 自动检测 mobilewindow.innerWidth < 768resize 防抖 200ms
*
* 用法:
* <SidebarProvider>
* <AppSidebar />
* <main className="flex-1">...</main>
* </SidebarProvider>
*
* 关联portal-shell README v2.0 §5.3 布局组件
*/
const MOBILE_BREAKPOINT = 768;
export interface SidebarContextValue {
/** 桌面端是否展开 */
expanded: boolean;
/** 移动端 Sheet 是否打开 */
openMobile: boolean;
/** 是否移动端 */
isMobile: boolean;
/** 切换桌面端展开/折叠 */
toggleExpanded: () => void;
/** 设置桌面端展开状态 */
setExpanded: (v: boolean) => void;
/** 切换移动端 Sheet */
toggleMobile: () => void;
/** 设置移动端 Sheet */
setOpenMobile: (v: boolean) => void;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
export function SidebarProvider({
children,
defaultExpanded = true,
className,
}: {
children: ReactNode;
defaultExpanded?: boolean;
className?: string;
}): ReactNode {
const [expanded, setExpanded] = useState(defaultExpanded);
const [openMobile, setOpenMobile] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
check();
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(check, 200);
};
window.addEventListener("resize", debounced);
return () => {
window.removeEventListener("resize", debounced);
if (timer) clearTimeout(timer);
};
}, []);
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
const toggleMobile = useCallback(() => setOpenMobile((v) => !v), []);
const value = useMemo<SidebarContextValue>(
() => ({
expanded,
openMobile,
isMobile,
toggleExpanded,
setExpanded,
toggleMobile,
setOpenMobile,
}),
[expanded, openMobile, isMobile, toggleExpanded, toggleMobile],
);
return (
<SidebarContext.Provider value={value}>
<div className={cn("flex min-h-screen w-full", className)}>
{children}
</div>
</SidebarContext.Provider>
);
}
export function useSidebar(): SidebarContextValue {
const ctx = useContext(SidebarContext);
if (!ctx) {
throw new Error("useSidebar 必须在 <SidebarProvider> 内部使用");
}
return ctx;
}
/** 侧边栏宽度类名(展开 256px / 折叠 64px */
export const SIDEBAR_WIDTH_EXPANDED = "w-64";
export const SIDEBAR_WIDTH_COLLAPSED = "w-16";

View File

@@ -0,0 +1,96 @@
"use client";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight, Menu } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Separator } from "@/shared/components/ui/separator";
import { cn } from "@/shared/lib/utils";
import { useSidebar } from "./sidebar-provider";
/**
* SiteHeader - 顶部头部组件(对齐 CICD site-header.tsx
*
* 结构Mobile Toggle + Separator + Breadcrumb + 右侧 actions搜索/通知/头像)
* - sticky top-0 z-50 h-16 bg-background/95 backdrop-blur-sm
* - 面包屑从 pathname 自动生成
*
* 关联portal-shell README v2.0 §5.3 布局组件
*/
export interface SiteHeaderProps {
/** 面包屑映射表path → title未命中时 fallback 到首字母大写 */
breadcrumbMap?: Record<string, string>;
/** 右侧操作区(搜索/通知/头像等) */
actions?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function SiteHeader({
breadcrumbMap = {},
actions,
className,
}: SiteHeaderProps): ReactNode {
const pathname = usePathname();
const { toggleMobile, isMobile } = useSidebar();
const segments = pathname.split("/").filter(Boolean);
return (
<header
className={cn(
"sticky top-0 z-50 flex h-16 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60",
className,
)}
>
{isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleMobile}
className="md:hidden"
>
<Menu className="size-5" />
<span className="sr-only"></span>
</Button>
)}
<Separator orientation="vertical" className="mx-1 h-6" />
{/* 面包屑 */}
<nav aria-label="面包屑" className="flex items-center gap-1 text-sm">
<Link
href="/"
className="text-muted-foreground transition-colors hover:text-foreground"
>
</Link>
{segments.map((seg, idx) => {
const href = "/" + segments.slice(0, idx + 1).join("/");
const isLast = idx === segments.length - 1;
const title =
breadcrumbMap[href] ?? seg.charAt(0).toUpperCase() + seg.slice(1);
return (
<span key={href} className="flex items-center gap-1">
<ChevronRight className="size-3 text-muted-foreground" />
{isLast ? (
<span className="font-medium text-foreground">{title}</span>
) : (
<Link
href={href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{title}
</Link>
)}
</span>
);
})}
</nav>
<div className="ml-auto flex items-center gap-2">{actions}</div>
</header>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { Suspense, type ReactNode } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
import { useErrorReport } from "@edu/hooks";
import { ErrorBoundary } from "@edu/ui-components";
/**
* PluginBoundary - 插件级错误边界 + 流式 Suspense替代 PluginLoader
*
* 三件套组合ErrorBoundary + Suspense + Skeleton
* 职责:
* 1. 隔离单个插件渲染错误,不影响其他插件和 Shell
* 2. 插件 dynamic import 期间显示骨架屏(流式渲染)
* 3. 错误自动上报到 /api/log通过 onError 回调,避免 fallback render phase 副作用)
*
* 5 种骨架变体(对齐 CICD DashboardSectionSkeleton
* - card通用卡片骨架标题 + 内容块)
* - list列表骨架多行
* - chart图表骨架坐标轴 + 柱状)
* - stats统计数据骨架大数字 + 标签)
* - table表格骨架表头 + 多行)
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L3 插件级)
*/
export type PluginSkeletonVariant =
"card" | "list" | "chart" | "stats" | "table";
export interface PluginBoundaryProps {
/** 插件实例 ID用于错误标识和上报 */
pluginId: string;
/** 子节点(插件组件) */
children: ReactNode;
/** 骨架变体(默认 card */
skeletonVariant?: PluginSkeletonVariant;
/** 自定义类名 */
className?: string;
}
/**
* 5 种骨架变体实现(对齐 CICD DashboardSectionSkeleton
*/
export function PluginSkeleton({
variant = "card",
className,
}: {
variant?: PluginSkeletonVariant;
className?: string;
}): ReactNode {
if (variant === "table") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("space-y-3 rounded-xl border bg-card p-6", className)}
>
<Skeleton className="h-6 w-1/4" />
<div className="space-y-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
);
}
if (variant === "list") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("space-y-2", className)}
>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (variant === "chart") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("rounded-xl border bg-card p-6", className)}
>
<Skeleton className="mb-4 h-6 w-1/3" />
<div className="flex h-40 items-end gap-2">
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
<Skeleton
key={i}
className="flex-1 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</div>
);
}
if (variant === "stats") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("rounded-xl border bg-card p-6", className)}
>
<Skeleton className="mb-4 h-6 w-1/3" />
<div className="flex gap-4">
{[0, 1, 2].map((i) => (
<div key={i} className="flex-1 space-y-2">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
</div>
);
}
// card默认
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("rounded-xl border bg-card p-6", className)}
>
<Skeleton className="mb-4 h-6 w-1/3" />
<Skeleton className="h-8 w-1/2" />
</div>
);
}
/**
* 插件错误兜底 UI纯展示组件不上报——上报由外层 onError 负责)
*/
function PluginErrorFallback({
pluginId,
error,
onReset,
}: {
pluginId: string;
error: Error;
onReset: () => void;
}): ReactNode {
return (
<div
role="alert"
aria-live="assertive"
className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6"
>
<AlertCircle className="size-8 text-destructive" />
<div className="text-center">
<p className="text-sm font-medium"></p>
<p className="mt-1 text-xs text-muted-foreground">
{pluginId}: {error.message}
</p>
</div>
<Button onClick={onReset} variant="outline" size="sm">
<RefreshCw className="size-4" />
</Button>
</div>
);
}
/**
* PluginBoundary - 插件错误边界 + 流式 Suspense
*
* @example
* <PluginBoundary pluginId="grades-widget" skeletonVariant="table">
* <GradesWidget {...pluginProps} />
* </PluginBoundary>
*/
export function PluginBoundary({
pluginId,
children,
skeletonVariant = "card",
className,
}: PluginBoundaryProps): ReactNode {
const reportError = useErrorReport();
return (
<ErrorBoundary
fallback={(error, reset) => (
<PluginErrorFallback
pluginId={pluginId}
error={error}
onReset={reset}
/>
)}
onError={(error) => {
void reportError(error, { pluginId, level: "error" });
}}
>
<Suspense
fallback={
<PluginSkeleton variant={skeletonVariant} className={className} />
}
>
{children}
</Suspense>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { useErrorReport } from "@edu/hooks";
/**
* RouteErrorBoundary - 路由级错误兜底(用于 app/shell/error.tsx
*
* Next.js App Router 的 error.tsx 接收 { error, reset } props
* - error: 触发的错误实例(含 digest
* - reset: 重置错误边界,重新渲染 Route Segment
*
* 本组件职责:
* 1. 上报错误到 /api/log通过 useErrorReport
* 2. 渲染统一错误 UI图标 + 标题 + 描述 + 重试按钮)
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L1 路由级)
*
* @example
* // app/shell/error.tsx
* "use client";
* import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
* export default function ShellError({ error, reset }) {
* return <RouteErrorBoundary error={error} reset={reset} namespace="shell" />;
* }
*/
export interface RouteErrorBoundaryProps {
/** Next.js error.tsx 注入的错误实例 */
error: Error & { digest?: string };
/** Next.js error.tsx 注入的重置函数 */
reset: () => void;
/** 命名空间(用于错误标题,如 "shell" / "admin" / "teacher" */
namespace?: string;
}
export function RouteErrorBoundary({
error,
reset,
namespace = "page",
}: RouteErrorBoundaryProps): React.ReactNode {
const reportError = useErrorReport();
useEffect(() => {
void reportError(error, { level: "error" });
}, [error, reportError]);
return (
<div
role="alert"
aria-live="assertive"
className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8"
>
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-6 text-destructive" />
</div>
<div className="text-center">
<h2 className="text-lg font-semibold">{namespace}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{error.message || "发生未知错误,请稍后重试"}
</p>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground/70">
{error.digest}
</p>
)}
</div>
<Button onClick={reset} variant="outline" size="sm">
<RefreshCw className="size-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
/**
* SectionErrorBoundary - 区块级错误边界(用于 DashboardSection 内)
*
* 职责:隔离单个区块(如统计卡片组、图表区、列表区)的渲染错误,
* 不影响其他区块和整个页面。
*
* 与 RouteErrorBoundary 的区别:
* - RouteErrorBoundary整页崩溃兜底由 Next.js error.tsx 触发
* - SectionErrorBoundary区块崩溃隔离由 DashboardSection 内部挂载
*
* 与 PluginBoundary 的区别:
* - PluginBoundary单个插件崩溃隔离含 Suspense + Skeleton
* - SectionErrorBoundary区块级可能含多个插件无 Suspense
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L2 区块级)
*/
export interface SectionErrorBoundaryProps {
children: ReactNode;
/** 区块标题(用于错误 UI 显示,如 "统计概览" */
title?: string;
/** 自定义错误降级 UI */
fallback?: (error: Error, reset: () => void) => ReactNode;
/** 错误回调(上报) */
onError?: (error: Error, info: ErrorInfo) => void;
/** 自定义类名 */
className?: string;
}
interface SectionErrorBoundaryState {
error: Error | null;
}
export class SectionErrorBoundary extends Component<
SectionErrorBoundaryProps,
SectionErrorBoundaryState
> {
override state: SectionErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): SectionErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
this.props.onError?.(error, info);
}
reset = (): void => {
this.setState({ error: null });
};
override render(): ReactNode {
const { error } = this.state;
const { children, fallback, title, className } = this.props;
if (error) {
if (fallback) {
return fallback(error, this.reset);
}
return (
<div
role="alert"
aria-live="assertive"
className={cn(
"flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6",
className,
)}
>
<AlertCircle className="size-8 text-destructive" />
<div className="text-center">
<p className="text-sm font-medium">{title ?? "区块加载失败"}</p>
<p className="mt-1 text-xs text-muted-foreground">
{error.message}
</p>
</div>
<Button onClick={this.reset} variant="outline" size="sm">
<RefreshCw className="size-4" />
</Button>
</div>
);
}
return children;
}
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps): React.ReactNode {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps): React.ReactNode {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground rounded-xl border shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold tracking-tight", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("p-6 pt-0", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,90 @@
import { type ReactNode, memo } from "react";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import { Button, type ButtonProps } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
/**
* EmptyState - 空态/错误降级展示(对齐 CICD empty-state.tsx
*
* 用途:
* - 空列表(如"暂无成绩记录"
* - 空搜索结果(如"未找到匹配项"
* - 错误降级(配合 ErrorBoundary
*
* React.memo 优化:高频渲染场景避免无谓重渲染
*
* @example
* <EmptyState
* icon={InboxIcon}
* title="暂无数据"
* description="点击下方按钮添加第一条记录"
* action={{ label: "添加", href: "/new", onClick: handleAdd }}
* />
*/
export interface EmptyStateAction {
/** 按钮文字 */
label: string;
/** 跳转链接(与 onClick 二选一) */
href?: string;
/** 点击回调(与 href 二选一) */
onClick?: () => void;
/** 按钮变体(默认 outline */
variant?: ButtonProps["variant"];
}
export interface EmptyStateProps {
/** 图标lucide-react 图标组件) */
icon?: LucideIcon;
/** 标题 */
title: string;
/** 描述文字 */
description?: string;
/** 操作按钮 */
action?: EmptyStateAction;
/** 自定义类名(默认最小高度 450px */
className?: string;
}
export const EmptyState = memo(function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps): ReactNode {
return (
<div
className={cn(
"flex min-h-[400px] flex-col items-center justify-center gap-4 p-8 text-center",
className,
)}
>
{Icon && (
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Icon className="size-6 text-muted-foreground" />
</div>
)}
<div className="space-y-1">
<p className="text-lg font-semibold">{title}</p>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{action &&
(action.href ? (
<Button asChild variant={action.variant ?? "outline"}>
<Link href={action.href}>{action.label}</Link>
</Button>
) : (
<Button
onClick={action.onClick}
variant={action.variant ?? "outline"}
>
{action.label}
</Button>
))}
</div>
);
});

View File

@@ -0,0 +1,118 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { cn } from "@/shared/lib/utils";
/**
* FilterBar - 筛选栏布局容器(对齐 CICD filter-bar.tsx
*
* 三种布局变体:
* - default左对齐默认
* - wrap自动换行筛选条件多时
* - between两端对齐左筛选 + 右操作)
*
* 移动端纵向 flex-col桌面端 md:flex-row md:items-center
* URL 状态管理方式由各模块自行处理FilterBar 只负责布局
*
* @example
* <FilterBar variant="between">
* <FilterSearchInput placeholder="搜索..." value={q} onChange={setQ} />
* <FilterResetButton onClick={reset} />
* <Button>新建</Button>
* </FilterBar>
*/
export interface FilterBarProps {
children: ReactNode;
/** 布局变体 */
variant?: "default" | "wrap" | "between";
/** 自定义类名 */
className?: string;
}
const VARIANT_CLASS: Record<NonNullable<FilterBarProps["variant"]>, string> = {
default: "md:flex-row md:items-center",
wrap: "md:flex-row md:items-center md:flex-wrap",
between: "md:flex-row md:items-center md:justify-between",
};
export function FilterBar({
children,
variant = "default",
className,
}: FilterBarProps): ReactNode {
return (
<div
className={cn("flex flex-col gap-2", VARIANT_CLASS[variant], className)}
>
{children}
</div>
);
}
/**
* FilterSearchInput - 带搜索图标的输入框
*
* 固定宽度 md:w-80移动端 100%
*/
export function FilterSearchInput({
placeholder = "搜索...",
value,
onChange,
className,
}: {
placeholder?: string;
value: string;
onChange: (v: string) => void;
className?: string;
}): ReactNode {
return (
<div className={cn("relative w-full md:w-80", className)}>
<svg
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Input
type="search"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9"
/>
</div>
);
}
/**
* FilterResetButton - 重置筛选按钮
*/
export function FilterResetButton({
onClick,
className,
}: {
onClick: () => void;
className?: string;
}): ReactNode {
return (
<Button
variant="ghost"
size="sm"
onClick={onClick}
className={cn("h-9", className)}
>
<X className="size-4" />
</Button>
);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
/**
* Input - shadcn 输入框(基础组件)
*
* 对齐 shadcn/ui 标准 Input 实现。
* 关联components.json aliases.ui
*/
function Input({
className,
type,
...props
}: React.ComponentProps<"input">): React.ReactNode {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/utils";
/**
* PageHeader - 页面标题区(对齐 CICD page-header.tsx
*
* 结构:左侧(图标 + 标题 + 描述)+ 右侧 actions
* 响应式:移动端纵向 flex-col桌面端 md:flex-row md:items-center
*
* @example
* <PageHeader
* title="成绩管理"
* description="查看和管理学生成绩"
* icon={<GraduationCap />}
* actions={<Button>导出</Button>}
* />
*/
export interface PageHeaderProps {
/** 页面标题 */
title: string;
/** 描述文字(可选) */
description?: string;
/** 标题前图标(可选) */
icon?: ReactNode;
/** 右侧操作区(按钮、筛选器等) */
actions?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function PageHeader({
title,
description,
icon,
actions,
className,
}: PageHeaderProps): ReactNode {
return (
<div
className={cn(
"flex flex-col gap-4 md:flex-row md:items-center md:justify-between",
className,
)}
>
<div className="flex items-start gap-3">
{icon && (
<div className="mt-1 text-muted-foreground [&_svg]:size-7">
{icon}
</div>
)}
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
{title}
</h1>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/shared/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>): React.ReactNode {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-[1px] data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[1px]",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactNode {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,38 @@
"use client";
import { Toaster as Sonner } from "sonner";
import { usePluginStore } from "@/shell/PluginStore";
type ToasterProps = React.ComponentProps<typeof Sonner>;
/**
* Toast 容器(基于 sonner
*
* 主题跟随 portal-shell PluginStore.themelight/dark不依赖 next-themes。
* 业务代码通过 `import { toast } from "sonner"` 直接调用。
*/
function Toaster({ ...props }: ToasterProps): React.ReactNode {
const theme = usePluginStore((s) => s.theme);
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
}
export { Toaster };

View File

@@ -0,0 +1,127 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
/**
* StatCard - 统计卡片(对齐 CICD stat-card.tsx
*
* 结构CardHeader标题 + 图标)+ CardContent数值 + 描述)
* - 加载态StatCardSkeleton
* - 高亮态border-amber-200 bg-amber-50/50用于关键指标
* - 可点击href 传入则包裹 Linkhover 微交互
*
* @example
* <StatCard
* title="学生总数"
* value={1234}
* icon={UsersIcon}
* description="较上月 +12"
* href="/admin/users"
* />
*/
export interface StatCardProps {
/** 卡片标题 */
title: string;
/** 数值(数字或字符串) */
value: number | string;
/** 图标lucide-react 图标组件) */
icon?: LucideIcon;
/** 描述文字(如"较上月 +12" */
description?: string;
/** 是否高亮(关键指标,默认 false */
highlight?: boolean;
/** 点击跳转链接 */
href?: string;
/** 是否加载中 */
isLoading?: boolean;
/** 数值类名(如 tabular-nums */
valueClassName?: string;
/** 自定义类名 */
className?: string;
}
export function StatCard({
title,
value,
icon: Icon,
description,
highlight = false,
href,
isLoading = false,
valueClassName,
className,
}: StatCardProps): ReactNode {
if (isLoading) {
return <StatCardSkeleton className={className} />;
}
const content = (
<Card
className={cn(
"transition-all",
href && "hover:-translate-y-1 hover:shadow-md",
highlight && "border-amber-200 bg-amber-50/50",
className,
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{Icon && <Icon className="size-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div
className={cn("text-2xl font-bold tracking-tight", valueClassName)}
>
{value}
</div>
{description && (
<CardDescription className="mt-1 text-xs">
{description}
</CardDescription>
)}
</CardContent>
</Card>
);
if (href) {
return (
<Link href={href} className="block">
{content}
</Link>
);
}
return content;
}
/** StatCard 骨架屏 */
export function StatCardSkeleton({
className,
}: {
className?: string;
}): ReactNode {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="size-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-16" />
<Skeleton className="mt-2 h-3 w-20" />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/utils";
/**
* StatsGrid - 统计卡片网格(对齐 CICD stats-grid.tsx
*
* 响应式列数mobile=1, md=2, lg=N由 columns prop 控制)
* 统一 isLoading 透传到所有子 StatCard
*
* @example
* <StatsGrid columns={4} isLoading={loading}>
* <StatCard title="学生" value={100} />
* <StatCard title="教师" value={20} />
* </StatsGrid>
*/
export interface StatsGridProps {
/** 子节点(通常是多个 StatCard */
children: ReactNode;
/** 桌面端列数1-5默认 4 */
columns?: 1 | 2 | 3 | 4 | 5;
/** 自定义类名 */
className?: string;
}
const COLUMNS_CLASS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
5: "md:grid-cols-5",
};
export function StatsGrid({
children,
columns = 4,
className,
}: StatsGridProps): ReactNode {
return (
<div
className={cn(
"grid grid-cols-1 gap-4",
COLUMNS_CLASS[columns],
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/utils";
/**
* Tooltip - shadcn 提示组件
*
* 对齐 shadcn/ui 标准 Tooltip 实现。
* 关联components.json aliases.ui
*/
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>): React.ReactNode {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };