367 行
14 KiB
TypeScript
367 行
14 KiB
TypeScript
import { useAuth } from "@/_core/hooks/useAuth";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarHeader,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarProvider,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
} from "@/components/ui/sidebar";
|
|
import { useIsMobile } from "@/hooks/useMobile";
|
|
import {
|
|
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
|
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
|
|
BookOpen, Bell, Microscope, ScrollText, Shield
|
|
} from "lucide-react";
|
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
|
import { useLocation, Redirect } from "wouter";
|
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
|
import { TaskCenter } from "./TaskCenter";
|
|
|
|
type MenuItem = {
|
|
icon: typeof LayoutDashboard;
|
|
label: string;
|
|
path: string;
|
|
group: "main" | "analysis" | "stats" | "learn";
|
|
adminOnly?: boolean;
|
|
};
|
|
|
|
const menuItems: MenuItem[] = [
|
|
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard", group: "main" },
|
|
{ icon: Target, label: "训练计划", path: "/training", group: "main" },
|
|
{ icon: Flame, label: "成就系统", path: "/checkin", group: "main" },
|
|
{ icon: Camera, label: "实时分析", path: "/live-camera", group: "analysis" },
|
|
{ icon: CircleDot, label: "在线录制", path: "/recorder", group: "analysis" },
|
|
{ icon: Video, label: "视频分析", path: "/analysis", group: "analysis" },
|
|
{ icon: FileVideo, label: "视频库", path: "/videos", group: "analysis" },
|
|
{ icon: Activity, label: "训练进度", path: "/progress", group: "stats" },
|
|
{ icon: Award, label: "NTRP评分", path: "/rating", group: "stats" },
|
|
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
|
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
|
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
|
{ icon: ScrollText, label: "更新日志", path: "/changelog", group: "learn" },
|
|
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
|
|
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
|
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },
|
|
];
|
|
|
|
const mobileNavItems = [
|
|
{ icon: LayoutDashboard, label: "首页", path: "/dashboard" },
|
|
{ icon: Target, label: "计划", path: "/training" },
|
|
{ icon: CircleDot, label: "录制", path: "/recorder" },
|
|
{ icon: FileVideo, label: "视频", path: "/videos" },
|
|
{ icon: Activity, label: "进度", path: "/progress" },
|
|
];
|
|
|
|
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
|
const DEFAULT_WIDTH = 260;
|
|
const MIN_WIDTH = 200;
|
|
const MAX_WIDTH = 400;
|
|
|
|
export default function DashboardLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
|
});
|
|
const { loading, user } = useAuth();
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
|
}, [sidebarWidth]);
|
|
|
|
if (loading) {
|
|
return <DashboardLayoutSkeleton />
|
|
}
|
|
|
|
if (!user) {
|
|
return <Redirect to="/login" />;
|
|
}
|
|
|
|
return (
|
|
<SidebarProvider
|
|
style={
|
|
{
|
|
"--sidebar-width": `${sidebarWidth}px`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
|
{children}
|
|
</DashboardLayoutContent>
|
|
</SidebarProvider>
|
|
);
|
|
}
|
|
|
|
type DashboardLayoutContentProps = {
|
|
children: React.ReactNode;
|
|
setSidebarWidth: (width: number) => void;
|
|
};
|
|
|
|
function DashboardLayoutContent({
|
|
children,
|
|
setSidebarWidth,
|
|
}: DashboardLayoutContentProps) {
|
|
const { user, logout } = useAuth();
|
|
const [location, setLocation] = useLocation();
|
|
const { state, toggleSidebar } = useSidebar();
|
|
const isCollapsed = state === "collapsed";
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
const visibleMenuItems = menuItems.filter(item => !item.adminOnly || user?.role === "admin");
|
|
const activeMenuItem = visibleMenuItems.find(item => item.path === location);
|
|
const isMobile = useIsMobile();
|
|
|
|
useEffect(() => {
|
|
if (isCollapsed) {
|
|
setIsResizing(false);
|
|
}
|
|
}, [isCollapsed]);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isResizing) return;
|
|
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
|
const newWidth = e.clientX - sidebarLeft;
|
|
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
|
setSidebarWidth(newWidth);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
};
|
|
|
|
if (isResizing) {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
}, [isResizing, setSidebarWidth]);
|
|
|
|
return (
|
|
<>
|
|
<div className="relative" ref={sidebarRef}>
|
|
<Sidebar
|
|
collapsible="icon"
|
|
className="border-r-0"
|
|
disableTransition={isResizing}
|
|
>
|
|
<SidebarHeader className="h-16 justify-center">
|
|
<div className="flex items-center gap-3 px-2 transition-all w-full">
|
|
<button
|
|
onClick={toggleSidebar}
|
|
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
|
aria-label="Toggle navigation"
|
|
>
|
|
<PanelLeft className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
{!isCollapsed ? (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Target className="h-5 w-5 text-primary shrink-0" />
|
|
<span className="font-semibold tracking-tight truncate text-sm">
|
|
Tennis Hub
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent className="gap-0">
|
|
<SidebarMenu className="px-2 py-1">
|
|
{/* Main group */}
|
|
{visibleMenuItems.filter(i => i.group === "main").map(item => {
|
|
const isActive = location === item.path;
|
|
return (
|
|
<SidebarMenuItem key={item.path}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
onClick={() => setLocation(item.path)}
|
|
tooltip={item.label}
|
|
className={`h-10 transition-all font-normal`}
|
|
>
|
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
|
|
{/* Divider */}
|
|
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
|
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">分析与录制</p>}
|
|
|
|
{visibleMenuItems.filter(i => i.group === "analysis").map(item => {
|
|
const isActive = location === item.path;
|
|
return (
|
|
<SidebarMenuItem key={item.path}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
onClick={() => setLocation(item.path)}
|
|
tooltip={item.label}
|
|
className={`h-10 transition-all font-normal`}
|
|
>
|
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
|
|
{/* Divider */}
|
|
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
|
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">统计与排名</p>}
|
|
|
|
{visibleMenuItems.filter(i => i.group === "stats").map(item => {
|
|
const isActive = location === item.path;
|
|
return (
|
|
<SidebarMenuItem key={item.path}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
onClick={() => setLocation(item.path)}
|
|
tooltip={item.label}
|
|
className={`h-10 transition-all font-normal`}
|
|
>
|
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
|
|
{/* Divider */}
|
|
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
|
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">学习与提醒</p>}
|
|
|
|
{visibleMenuItems.filter(i => i.group === "learn").map(item => {
|
|
const isActive = location === item.path;
|
|
return (
|
|
<SidebarMenuItem key={item.path}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
onClick={() => setLocation(item.path)}
|
|
tooltip={item.label}
|
|
className={`h-10 transition-all font-normal`}
|
|
>
|
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="p-3">
|
|
<div className="mb-3">
|
|
<TaskCenter />
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<Avatar className="h-9 w-9 border shrink-0">
|
|
<AvatarFallback className="text-xs font-medium bg-primary/10 text-primary">
|
|
{user?.name?.charAt(0).toUpperCase() || "U"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
|
<p className="text-sm font-medium truncate leading-none">
|
|
{user?.name || "用户"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
|
{user?.email || "网球训练中"}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem
|
|
onClick={logout}
|
|
className="cursor-pointer text-destructive focus:text-destructive"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>退出登录</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
<div
|
|
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
|
onMouseDown={() => {
|
|
if (isCollapsed) return;
|
|
setIsResizing(true);
|
|
}}
|
|
style={{ zIndex: 50 }}
|
|
/>
|
|
</div>
|
|
|
|
<SidebarInset>
|
|
{isMobile && (
|
|
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
|
|
<div className="flex items-center gap-2">
|
|
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="tracking-tight text-foreground text-sm">
|
|
{activeMenuItem?.label ?? "Tennis Hub"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<TaskCenter compact />
|
|
</div>
|
|
)}
|
|
<main className={`flex-1 p-4 md:p-6 ${isMobile ? "pb-28" : ""}`}>{children}</main>
|
|
{isMobile && (
|
|
<nav className="mobile-safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-border/70 bg-background/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur">
|
|
<div className="mx-auto grid max-w-xl grid-cols-5 gap-1">
|
|
{mobileNavItems.map((item) => {
|
|
const isActive = location === item.path;
|
|
return (
|
|
<button
|
|
key={item.path}
|
|
type="button"
|
|
onClick={() => setLocation(item.path)}
|
|
className={`flex min-h-[52px] flex-col items-center justify-center rounded-2xl px-1 py-2 text-[11px] transition ${
|
|
isActive
|
|
? "bg-primary text-primary-foreground shadow-sm"
|
|
: "text-muted-foreground hover:bg-muted/70"
|
|
}`}
|
|
>
|
|
<item.icon className="mb-1 h-4 w-4" />
|
|
<span>{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
)}
|
|
</SidebarInset>
|
|
</>
|
|
);
|
|
}
|