feat(admin): add prototype admin panel

- 新增管理员布局、仪表盘、侧边栏等组件
- 实现用户数量、问题数量等统计显示
- 添加 LSP 连接状态组件- 创建统计卡片组件用于展示各类数据
- 开发网格组件以支持灵活的布局
This commit is contained in:
fly6516 2025-06-15 11:56:17 +08:00
parent 941f1a74fa
commit aaa8629e76
8 changed files with 310 additions and 3 deletions

View File

@ -0,0 +1,21 @@
import { ReactNode } from "react";
import { AdminSidebar } from "@/components/admin/sidebar";
import { Header } from "@/components/header";
interface AdminLayoutProps {
children: ReactNode;
}
export default function AdminLayout({ children }: AdminLayoutProps) {
return (
<div className="flex min-h-screen">
<AdminSidebar />
<div className="flex-1 flex flex-col">
{/*<Header />*/}
<main className="flex-1 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { AdminSidebar } from "@/components/admin/sidebar";
import { Header } from "@/components/header";
import { AdminDashboard } from "@/components/admin/dashboard";
import AdminLayout from "@/app/(app)/admin/layout";
import type { ReactElement } from "react";
import prisma from "@/lib/prisma";
export default async function AdminPage(): Promise<ReactElement> {
const [userCount, problemCount] = await Promise.all([
prisma.user.count(),
prisma.problem.count(),
]);
return (
<AdminLayout>
<div className="flex min-h-screen">
<AdminSidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6">
<AdminDashboard userCount={userCount} problemCount={problemCount} />
</main>
</div>
</div>
</AdminLayout>
);
}

View File

@ -5,6 +5,8 @@ import { getLocale } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SettingsDialog } from "@/components/settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog";
import { SessionProvider } from "next-auth/react";
import { SidebarProvider } from "@/components/ui/sidebar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Judge4c", title: "Judge4c",
@ -29,9 +31,13 @@ export default async function RootLayout({ children }: RootLayoutProps) {
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<SessionProvider>
<SidebarProvider>
<div className="w-full">{children}</div> <div className="w-full">{children}</div>
<SettingsDialog /> <SettingsDialog />
<Toaster position="top-right" /> <Toaster position="top-right" />
</SidebarProvider>
</SessionProvider>
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>

View File

@ -0,0 +1,56 @@
import { Grid } from "@/components/ui/grid";
import StatCard from "./stat-card";
import { LspStatus } from "./lsp-status";
import { ProblemsetTable } from "@/features/problemset/components/table";
import { SubmissionTable } from "@/features/problems/submission/components/table";
import prisma from "@/lib/prisma";
import type { ReactNode } from "react";
interface AdminDashboardProps {
userCount?: number;
problemCount?: number;
}
export const AdminDashboard = async ({
userCount = 0,
problemCount = 0
}: AdminDashboardProps) => {
// 获取统计数据显示
const [usersCount, problemsCount, submissionsCount] = await Promise.all([
prisma.user.count(),
prisma.problem.count(),
prisma.submission.count(),
]);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
</p>
</div>
<Grid cols={3} gap={6}>
<StatCard title="用户数量" value={userCount || usersCount} />
<StatCard title="问题数量" value={problemCount || problemsCount} />
<StatCard title="测评次数" value={submissionsCount} />
</Grid>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<LspStatus />
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
{/* 临时使用空字符串作为占位符实际应从数据库获取最新问题ID */}
<SubmissionTable problemId="" />
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<ProblemsetTable />
</div>
</div>
);
};

View File

@ -0,0 +1,10 @@
import { LspConnectionIndicator } from "@/features/problems/code/components/toolbar/controls/lsp-connection-indicator";
export const LspStatus = () => {
return (
<div className="p-4">
<h3 className="text-lg font-semibold mb-2">LSP </h3>
<LspConnectionIndicator />
</div>
);
};

View File

@ -0,0 +1,119 @@
"use client"
import { useSession } from "next-auth/react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar";
import { NavMain } from "@/components/nav-main";
import { NavProjects } from "@/components/nav-projects";
import { TeamSwitcher } from "@/components/team-switcher";
import { NavUser } from "@/components/nav-user";
import {
AudioWaveform,
Bot,
Command,
Frame,
GalleryVerticalEnd,
Map,
PieChart,
Settings2,
SquareTerminal,
} from "lucide-react";
import { SessionProvider } from "next-auth/react";
import { SidebarProvider } from "@/components/ui/sidebar";
// 如果 adminData.teams 没有在别处定义,请取消注释下面的代码并提供实际值
/*
const teams = [
// 在这里放置你的团队数据
];
*/
// 创建图标映射
type LucideIconMap = {
[key: string]: typeof AudioWaveform;
};
const lucideIconMap: LucideIconMap = {
AudioWaveform,
Bot,
Command,
Frame,
GalleryVerticalEnd,
Map,
PieChart,
Settings2,
SquareTerminal,
};
const adminData = {
// teams: [
// {
// name: "Admin Team",
// logo: GalleryVerticalEnd,
// plan: "Enterprise",
// },
// ],
navMain: [
{
title: "Dashboard",
url: "/admin",
icon: Settings2,
items: [
{
title: "Overview",
url: "/admin",
},
{
title: "Users",
url: "/admin/users",
},
{
title: "Problems",
url: "/admin/problems",
},
],
},
],
projects: [
{
name: "System Monitoring",
url: "/admin/monitoring",
icon: PieChart,
},
{
name: "Admin Tools",
url: "/admin/tools",
icon: Command,
},
]
};
export const AdminSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
const { data: session } = useSession();
const user = {
name: session?.user?.name || "Admin",
email: session?.user?.email || "admin@example.com",
avatar: session?.user?.avatar || ""
};
return (
<Sidebar collapsible="icon" {...props}>
{/*<SidebarHeader>*/}
{/* <TeamSwitcher teams={adminData.teams} />*/}
{/*</SidebarHeader>*/}
<SidebarContent>
<NavMain items={adminData.navMain} />
<NavProjects projects={adminData.projects} />
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
};

View File

@ -0,0 +1,21 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface StatCardProps {
title: string;
value: number;
icon?: React.ReactNode;
}
export default function StatCard({ title, value, icon }: StatCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const gridVariants = cva(
"grid",
{
variants: {
cols: {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
},
gap: {
0: "gap-0",
2: "gap-2",
4: "gap-4",
6: "gap-6",
8: "gap-8",
},
},
defaultVariants: {
cols: 1,
gap: 4,
},
}
)
type GridProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof gridVariants> & {
as?: React.ElementType
}
const Grid = React.forwardRef<HTMLDivElement, GridProps>(
({ className, cols, gap, as: Comp = "div", ...props }, ref) => {
return (
<Comp
className={cn(gridVariants({ cols, gap }), className)}
ref={ref}
{...props}
/>
)
}
)
export { Grid }