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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
238
apps/portal-shell/src/shared/components/layout/app-sidebar.tsx
Normal file
238
apps/portal-shell/src/shared/components/layout/app-sidebar.tsx
Normal 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)
|
||||
* - 折叠态:仅图标 + Tooltip(hover 显示标题),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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
* - 自动检测 mobile(window.innerWidth < 768),resize 防抖 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
216
apps/portal-shell/src/shared/components/plugin-boundary.tsx
Normal file
216
apps/portal-shell/src/shared/components/plugin-boundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
37
apps/portal-shell/src/shared/components/ui/badge.tsx
Normal file
37
apps/portal-shell/src/shared/components/ui/badge.tsx
Normal 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 };
|
||||
60
apps/portal-shell/src/shared/components/ui/button.tsx
Normal file
60
apps/portal-shell/src/shared/components/ui/button.tsx
Normal 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 };
|
||||
75
apps/portal-shell/src/shared/components/ui/card.tsx
Normal file
75
apps/portal-shell/src/shared/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
90
apps/portal-shell/src/shared/components/ui/empty-state.tsx
Normal file
90
apps/portal-shell/src/shared/components/ui/empty-state.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
118
apps/portal-shell/src/shared/components/ui/filter-bar.tsx
Normal file
118
apps/portal-shell/src/shared/components/ui/filter-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/portal-shell/src/shared/components/ui/input.tsx
Normal file
31
apps/portal-shell/src/shared/components/ui/input.tsx
Normal 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 };
|
||||
64
apps/portal-shell/src/shared/components/ui/page-header.tsx
Normal file
64
apps/portal-shell/src/shared/components/ui/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/portal-shell/src/shared/components/ui/separator.tsx
Normal file
26
apps/portal-shell/src/shared/components/ui/separator.tsx
Normal 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 };
|
||||
18
apps/portal-shell/src/shared/components/ui/skeleton.tsx
Normal file
18
apps/portal-shell/src/shared/components/ui/skeleton.tsx
Normal 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 };
|
||||
38
apps/portal-shell/src/shared/components/ui/sonner.tsx
Normal file
38
apps/portal-shell/src/shared/components/ui/sonner.tsx
Normal 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.theme(light/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 };
|
||||
127
apps/portal-shell/src/shared/components/ui/stat-card.tsx
Normal file
127
apps/portal-shell/src/shared/components/ui/stat-card.tsx
Normal 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 传入则包裹 Link,hover 微交互
|
||||
*
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
50
apps/portal-shell/src/shared/components/ui/stats-grid.tsx
Normal file
50
apps/portal-shell/src/shared/components/ui/stats-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/shared/components/ui/tooltip.tsx
Normal file
38
apps/portal-shell/src/shared/components/ui/tooltip.tsx
Normal 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 };
|
||||
73
apps/portal-shell/src/shared/lib/notify.ts
Normal file
73
apps/portal-shell/src/shared/lib/notify.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* notify - 统一 Toast 通知封装(对齐 CICD notify.ts)
|
||||
*
|
||||
* 业务代码统一通过 notify 调用,禁止直接 `import { toast } from "sonner"`。
|
||||
* 优势:便于测试 mock、未来替换底层库、统一 i18n 入口。
|
||||
*
|
||||
* 用法:
|
||||
* import { notify } from "@/shared/lib/notify";
|
||||
* notify.success("保存成功");
|
||||
* notify.error("网络错误");
|
||||
* notify.promise(asyncFn, { loading: "保存中...", success: "成功", error: "失败" });
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4
|
||||
*/
|
||||
import { toast, type ExternalToast } from "sonner";
|
||||
|
||||
type Message = string;
|
||||
|
||||
interface NotifyPromiseOptions<T> {
|
||||
loading: Message;
|
||||
success: Message | ((data: T) => Message);
|
||||
error: Message | ((error: unknown) => Message);
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
/** 成功提示(默认 4 秒) */
|
||||
success(message: Message, options?: ExternalToast): void {
|
||||
toast.success(message, options);
|
||||
},
|
||||
|
||||
/** 错误提示(默认 6 秒,更长便于阅读) */
|
||||
error(message: Message, options?: ExternalToast): void {
|
||||
toast.error(message, { duration: 6000, ...options });
|
||||
},
|
||||
|
||||
/** 警告提示 */
|
||||
warning(message: Message, options?: ExternalToast): void {
|
||||
toast.warning(message, options);
|
||||
},
|
||||
|
||||
/** 信息提示 */
|
||||
info(message: Message, options?: ExternalToast): void {
|
||||
toast.info(message, options);
|
||||
},
|
||||
|
||||
/** 带加载状态的 Promise 提示(透传原 Promise,便于链式调用) */
|
||||
promise<T>(
|
||||
promise: Promise<T>,
|
||||
options: NotifyPromiseOptions<T>,
|
||||
): Promise<T> {
|
||||
toast.promise(promise, options);
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** 加载中提示(返回 toast id,可用 toast.dismiss(id) 关闭) */
|
||||
loading(message: Message, options?: ExternalToast): string | number {
|
||||
return toast.loading(message, options);
|
||||
},
|
||||
|
||||
/** 自定义提示(escape hatch,业务慎用) */
|
||||
message(message: Message, options?: ExternalToast): void {
|
||||
toast(message, options);
|
||||
},
|
||||
|
||||
/** 关闭所有提示 */
|
||||
dismiss(): void {
|
||||
toast.dismiss();
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { toast as rawToast } from "sonner";
|
||||
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 路由权限配置表(对齐 CICD 项目 route-permissions.ts)
|
||||
*
|
||||
* 4 张表按优先级顺序匹配(精确 > 前缀 > 仪表盘 > API):
|
||||
* 1. EXACT_ROUTE_PERMISSIONS:精确路由(如 /shell/admin/users)
|
||||
* 2. PREFIX_ROUTE_PERMISSIONS:前缀路由(如 /shell/admin/*)
|
||||
* 3. DASHBOARD_ROUTE_PERMISSIONS:仪表盘路由(按角色分发)
|
||||
* 4. API_ROUTE_PERMISSIONS:Next.js API Route(/api/*)
|
||||
*
|
||||
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||
* - L1 角色门禁:requiredRoles(4 角色之一)
|
||||
* - L2 权限点门禁:requiredPermissions(AND 语义,必须全部满足)
|
||||
* - L3 数据范围:运行时由插件/page 内 usePermission 校验
|
||||
*
|
||||
* 使用方式(middleware / page / layout):
|
||||
* ```ts
|
||||
* import { checkRoutePermission } from "@/shared/lib/route-permissions";
|
||||
*
|
||||
* const result = checkRoutePermission(pathname, userBitmap, userRole);
|
||||
* if (!result.allowed) redirect("/shell/forbidden");
|
||||
* ```
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §3.3、project_rules §3.1(禁止 role === "xxx" 硬编码)
|
||||
*/
|
||||
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
import {
|
||||
hasAllPermissionsInBitmap,
|
||||
hasAnyPermissionInBitmap,
|
||||
isValidPermission,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
|
||||
/**
|
||||
* 路由权限配置项
|
||||
*/
|
||||
export interface RoutePermissionConfig {
|
||||
/** 所需角色(任一满足即可;空数组表示不限制角色) */
|
||||
requiredRoles?: Role[];
|
||||
/**
|
||||
* 所需权限点(AND 语义,必须全部满足)
|
||||
* 权限点必须来自 PERMISSION_BITMAP_ORDER
|
||||
*/
|
||||
requiredPermissions?: string[];
|
||||
/**
|
||||
* 所需权限点(OR 语义,任一满足即可)
|
||||
* 与 requiredPermissions 同时存在时,先 AND 再 OR
|
||||
*/
|
||||
anyOfPermissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查结果
|
||||
*/
|
||||
export interface RoutePermissionResult {
|
||||
/** 是否允许访问 */
|
||||
allowed: boolean;
|
||||
/** 拒绝原因(allowed=false 时填充) */
|
||||
reason?: "missing_role" | "missing_permission" | "no_config";
|
||||
/** 匹配到的配置(用于调试) */
|
||||
matchedPath?: string;
|
||||
/** 缺失的权限点(allowed=false 且 reason=missing_permission 时填充) */
|
||||
missingPermissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 精确路由权限表
|
||||
*
|
||||
* 高优先级,pathname 完全匹配时生效。
|
||||
* 适用于功能明确、URL 固定的页面(用户管理、RBAC、审计日志等)。
|
||||
*/
|
||||
export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// ── admin 专属 ────────────────────────────────────────────
|
||||
"/shell/admin/users": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["USER_MANAGE"],
|
||||
},
|
||||
"/shell/admin/roles": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["ROLE_MANAGE"],
|
||||
},
|
||||
"/shell/admin/permissions": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["PERMISSION_MANAGE"],
|
||||
},
|
||||
"/shell/admin/audit-logs": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["AUDIT_LOG_READ"],
|
||||
},
|
||||
"/shell/admin/school": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["SCHOOL_MANAGE"],
|
||||
},
|
||||
"/shell/admin/plugins": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["PLUGIN_REGISTRY_MANAGE"],
|
||||
},
|
||||
"/shell/admin/invitation-codes": {
|
||||
requiredRoles: ["admin"],
|
||||
anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"],
|
||||
},
|
||||
|
||||
// ── teacher 专属 ──────────────────────────────────────────
|
||||
"/shell/teacher/lesson-plans": {
|
||||
requiredRoles: ["teacher"],
|
||||
anyOfPermissions: [
|
||||
"LESSON_PLAN_READ",
|
||||
"LESSON_PLAN_CREATE",
|
||||
"LESSON_PLAN_UPDATE",
|
||||
],
|
||||
},
|
||||
"/shell/teacher/question-bank": {
|
||||
requiredRoles: ["teacher"],
|
||||
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE"],
|
||||
},
|
||||
"/shell/teacher/textbooks": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
requiredPermissions: ["TEXTBOOK_READ"],
|
||||
},
|
||||
"/shell/teacher/scheduling-rules": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
|
||||
},
|
||||
|
||||
// ── student 专属 ──────────────────────────────────────────
|
||||
"/shell/student/error-book": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["ERROR_BOOK_READ"],
|
||||
},
|
||||
"/shell/student/learning-path": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["LEARNING_PATH_READ"],
|
||||
},
|
||||
"/shell/student/electives": {
|
||||
requiredRoles: ["student"],
|
||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_SELECT"],
|
||||
},
|
||||
"/shell/student/ai-tutor": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["AI_TUTOR_USE"],
|
||||
},
|
||||
|
||||
// ── parent 专属 ───────────────────────────────────────────
|
||||
"/shell/parent/children": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["GRADE_READ_CHILD"],
|
||||
},
|
||||
"/shell/parent/leave-approval": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["LEAVE_APPROVAL_MANAGE"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. 前缀路由权限表
|
||||
*
|
||||
* 中优先级,pathname 以指定前缀开头时生效。
|
||||
* 适用于功能集合下的所有子路由(/shell/admin/* /shell/teacher/exams/* 等)。
|
||||
*
|
||||
* 注意:前缀必须以 / 结尾,避免误匹配(如 /shell/admin 不能匹配 /shell/admin-users)。
|
||||
*/
|
||||
export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
prefix: string;
|
||||
config: RoutePermissionConfig;
|
||||
}> = [
|
||||
// admin 区所有子路由默认要求 admin 角色
|
||||
{
|
||||
prefix: "/shell/admin/",
|
||||
config: { requiredRoles: ["admin"] },
|
||||
},
|
||||
// 考试管理
|
||||
{
|
||||
prefix: "/shell/teacher/exams/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: [
|
||||
"EXAM_READ",
|
||||
"EXAM_CREATE",
|
||||
"EXAM_UPDATE",
|
||||
"EXAM_GRADE",
|
||||
],
|
||||
},
|
||||
},
|
||||
// 作业管理
|
||||
{
|
||||
prefix: "/shell/teacher/homework/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
|
||||
},
|
||||
},
|
||||
// 成绩录入
|
||||
{
|
||||
prefix: "/shell/teacher/grades/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"],
|
||||
},
|
||||
},
|
||||
// 考勤
|
||||
{
|
||||
prefix: "/shell/teacher/attendance/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 班级管理
|
||||
{
|
||||
prefix: "/shell/admin/classes/",
|
||||
config: {
|
||||
requiredRoles: ["admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 学情诊断
|
||||
{
|
||||
prefix: "/shell/teacher/diagnostics/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 公告管理
|
||||
{
|
||||
prefix: "/shell/admin/announcements/",
|
||||
config: {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 3. 仪表盘路由权限表
|
||||
*
|
||||
* 低优先级,按角色分发的根仪表盘。
|
||||
* 当 pathname 不匹配前两张表时,检查是否为角色仪表盘根路径。
|
||||
*/
|
||||
export const DASHBOARD_ROUTE_PERMISSIONS: Record<
|
||||
string,
|
||||
RoutePermissionConfig
|
||||
> = {
|
||||
"/shell/admin": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["DASHBOARD_ADMIN_READ"],
|
||||
},
|
||||
"/shell/teacher": {
|
||||
requiredRoles: ["teacher"],
|
||||
requiredPermissions: ["DASHBOARD_TEACHER_READ"],
|
||||
},
|
||||
"/shell/student": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["DASHBOARD_STUDENT_READ"],
|
||||
},
|
||||
"/shell/parent": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["DASHBOARD_PARENT_READ"],
|
||||
},
|
||||
// 通用仪表盘
|
||||
"/shell": {
|
||||
requiredPermissions: ["DASHBOARD_READ"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 4. Next.js API Route 权限表
|
||||
*
|
||||
* 用于 /api/* 路径的权限校验。
|
||||
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
||||
*/
|
||||
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// 错误上报端点:所有登录用户可访问
|
||||
"/api/log": {},
|
||||
// 健康检查:公开
|
||||
"/api/healthz": {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验权限配置的合法性(开发时辅助)
|
||||
*
|
||||
* 检查所有声明的权限点是否在 PERMISSION_BITMAP_ORDER 中。
|
||||
* 在 dev 模式下打 warning,生产构建时可阻断。
|
||||
*
|
||||
* @returns 非法权限点列表(空数组表示全部合法)
|
||||
*/
|
||||
export function validateRoutePermissionConfigs(): string[] {
|
||||
const invalid: string[] = [];
|
||||
const allConfigs: Array<{ source: string; config: RoutePermissionConfig }> = [
|
||||
...Object.entries(EXACT_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `EXACT:${path}`,
|
||||
config,
|
||||
})),
|
||||
...PREFIX_ROUTE_PERMISSIONS.map(({ prefix, config }) => ({
|
||||
source: `PREFIX:${prefix}`,
|
||||
config,
|
||||
})),
|
||||
...Object.entries(DASHBOARD_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `DASHBOARD:${path}`,
|
||||
config,
|
||||
})),
|
||||
...Object.entries(API_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `API:${path}`,
|
||||
config,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const { source, config } of allConfigs) {
|
||||
for (const perm of config.requiredPermissions ?? []) {
|
||||
if (!isValidPermission(perm)) {
|
||||
invalid.push(`${source}:requiredPermissions:${perm}`);
|
||||
}
|
||||
}
|
||||
for (const perm of config.anyOfPermissions ?? []) {
|
||||
if (!isValidPermission(perm)) {
|
||||
invalid.push(`${source}:anyOfPermissions:${perm}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查主函数
|
||||
*
|
||||
* 按优先级顺序匹配 4 张表,返回检查结果。
|
||||
*
|
||||
* @param pathname 当前路径(如 /shell/admin/users)
|
||||
* @param userBitmap 用户权限位图(base36 字符串,从 JWT cookie 解析)
|
||||
* @param userRole 用户角色
|
||||
* @returns 检查结果,allowed=true 表示放行
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = checkRoutePermission("/shell/admin/users", "abc123", "admin");
|
||||
* if (!result.allowed) {
|
||||
* redirect("/shell/forbidden");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function checkRoutePermission(
|
||||
pathname: string,
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): RoutePermissionResult {
|
||||
// 1. 匹配精确路由
|
||||
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
||||
if (exactConfig) {
|
||||
return evaluateConfig(exactConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
|
||||
// 2. 匹配前缀路由
|
||||
for (const { prefix, config } of PREFIX_ROUTE_PERMISSIONS) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
return evaluateConfig(config, userBitmap, userRole, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 匹配仪表盘路由
|
||||
const dashboardConfig = DASHBOARD_ROUTE_PERMISSIONS[pathname];
|
||||
if (dashboardConfig) {
|
||||
return evaluateConfig(dashboardConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
|
||||
// 4. 匹配 API 路由
|
||||
if (pathname.startsWith("/api/")) {
|
||||
const apiConfig = API_ROUTE_PERMISSIONS[pathname];
|
||||
if (apiConfig) {
|
||||
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
// 未配置的 API 路由默认拒绝
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估单个权限配置
|
||||
*/
|
||||
function evaluateConfig(
|
||||
config: RoutePermissionConfig,
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
matchedPath: string,
|
||||
): RoutePermissionResult {
|
||||
// L1 角色门禁
|
||||
if (config.requiredRoles && config.requiredRoles.length > 0) {
|
||||
if (!config.requiredRoles.includes(userRole)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_role",
|
||||
matchedPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// L2 权限点门禁 - AND 语义
|
||||
const missingPermissions: string[] = [];
|
||||
if (config.requiredPermissions && config.requiredPermissions.length > 0) {
|
||||
for (const perm of config.requiredPermissions) {
|
||||
// 使用 hasAllPermissionsInBitmap 不合适(它返回 boolean 不告知哪些缺失)
|
||||
// 这里手动遍历以便收集缺失项
|
||||
const bit = hasPermissionInBitmapSimple(userBitmap, perm);
|
||||
if (!bit) {
|
||||
missingPermissions.push(perm);
|
||||
}
|
||||
}
|
||||
if (missingPermissions.length > 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_permission",
|
||||
matchedPath,
|
||||
missingPermissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// L2 权限点门禁 - OR 语义
|
||||
if (config.anyOfPermissions && config.anyOfPermissions.length > 0) {
|
||||
if (!hasAnyPermissionInBitmap(userBitmap, config.anyOfPermissions)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_permission",
|
||||
matchedPath,
|
||||
missingPermissions: config.anyOfPermissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, matchedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版单权限检查(避免循环依赖 hasAllPermissionsInBitmap)
|
||||
*
|
||||
* 直接调用 hasAllPermissionsInBitmap 检查单个权限点
|
||||
*/
|
||||
function hasPermissionInBitmapSimple(
|
||||
bitmap: string,
|
||||
permission: string,
|
||||
): boolean {
|
||||
return hasAllPermissionsInBitmap(bitmap, [permission]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查用户是否拥有所有指定路由的访问权限
|
||||
*
|
||||
* 用于侧边栏导航项过滤:一次性检查多个路由,避免重复调用。
|
||||
*
|
||||
* @param paths 路径列表
|
||||
* @param userBitmap 用户权限位图
|
||||
* @param userRole 用户角色
|
||||
* @returns 路径 → 是否允许 的映射
|
||||
*/
|
||||
export function batchCheckRoutePermission(
|
||||
paths: readonly string[],
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const path of paths) {
|
||||
result[path] = checkRoutePermission(path, userBitmap, userRole).allowed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
54
apps/portal-shell/src/shared/lib/utils.ts
Normal file
54
apps/portal-shell/src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 类名合并 + 通用工具函数(对齐 CICD 项目 src/shared/lib/utils.ts)
|
||||
*
|
||||
* shadcn/ui 组件统一通过 `@/shared/lib/utils` 引用 cn()。
|
||||
* 关联:project_rules §3.9、components.json aliases.utils
|
||||
*/
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Next.js App Router 搜索参数类型 */
|
||||
export type SearchParams = { [key: string]: string | string[] | undefined };
|
||||
|
||||
/** 从 SearchParams 中安全提取单个字符串值 */
|
||||
export function getSearchParam(
|
||||
params: SearchParams,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const v = params[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v)) return v[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 格式化数字,null/undefined/非有限数返回 "-" */
|
||||
export function formatNumber(v: number | null | undefined, digits = 1): string {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return "-";
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从姓名生成头像占位用的首字母(最多 2 个字符)。
|
||||
* 用于 AvatarFallback 组件。
|
||||
* - 含空格的姓名:取各单词首字母拼接(如 "John Doe" -> "JD")
|
||||
* - 无空格的姓名:取前 2 个字符(如 "张三" -> "张三")
|
||||
* - 空值:返回 "U"(User 通用占位)
|
||||
*/
|
||||
export function getInitials(name: string | null | undefined): string {
|
||||
if (!name) return "U";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "U";
|
||||
if (trimmed.includes(" ")) {
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return trimmed.slice(0, 2).toUpperCase();
|
||||
}
|
||||
Reference in New Issue
Block a user