Files
admin-portal/src/components/layout/AppShell.tsx
T

131 lines
4.4 KiB
TypeScript
Raw Normal View History

import { NavLink, Outlet } from "react-router-dom";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { cn } from "@/lib/utils";
import { navForRole } from "@/lib/rbac";
import { useAuth } from "@/lib/auth";
import {
Avatar,
AvatarFallback,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function AppShell() {
const { role, user, logout } = useAuth();
const groups = navForRole(role);
// 展示文本标签(账号类型): 运营方/载体方/OPC/服务方。
// 优先按 role 映射(取自身份/端口角色, 可靠), 兜底用后端下发的 account_type_label。
const roleToLabel: Record<string, string> = {
operator: "运营方",
carrier: "载体方",
opc_member: "OPC",
service: "服务方",
};
const atypeLabel = (user as { account_type_label?: string })?.account_type_label;
const roleLabel = roleToLabel[role ?? ""] || atypeLabel || role || "运营方";
return (
<div className="flex min-h-screen">
{/* 侧边栏 */}
<aside className="fixed inset-y-0 left-0 z-30 flex w-60 flex-col border-r bg-card">
<div className="flex h-14 items-center gap-2 px-4">
<img src="/logo.png" alt="云超服" className="h-7 w-7 object-contain" />
<span className="font-semibold"> · </span>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-4">
{groups.map((g, gi) => (
<div key={gi} className="mb-4">
{g.title && (
<div className="px-2 pb-1 text-xs font-medium uppercase text-muted-foreground">
{g.title}
</div>
)}
<div className="space-y-1">
{g.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</div>
</div>
))}
</nav>
<div className="border-t p-2">
<UserMenu
roleLabel={roleLabel}
username={user?.username || "账号"}
onLogout={logout}
/>
</div>
</aside>
{/* 主内容 */}
<div className="flex-1 pl-60">
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b bg-background/95 px-6 backdrop-blur">
<div className="text-sm text-muted-foreground">
<span className="ml-1 font-medium text-foreground">{roleLabel}</span>
</div>
</header>
<main className="p-6">
<Outlet />
</main>
</div>
</div>
);
}
function UserMenu({
roleLabel,
username,
onLogout,
}: {
roleLabel: string;
username: string;
onLogout: () => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 px-2">
<Avatar className="h-7 w-7">
<AvatarFallback>{(username || "A").slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start leading-tight">
<span className="text-sm">{username}</span>
<span className="text-xs text-muted-foreground">{roleLabel}</span>
</div>
<ChevronsUpDown className="ml-auto h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>{username}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="h-4 w-4" />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}