feat: Enhance admin dashboard with comprehensive system overview and statistics

This commit is contained in:
wood chen 2025-02-20 00:19:29 +08:00
parent dcf36824ed
commit 98e5563eb0
10 changed files with 879 additions and 54 deletions

4
package-lock.json generated
View File

@ -36,8 +36,8 @@
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@types/crypto-js": "^4.2.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.7",

View File

@ -43,8 +43,8 @@
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@types/crypto-js": "^4.2.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.7",

View File

@ -80,6 +80,7 @@ model AccessToken {
id String @id @default(cuid())
token String @unique
expiresAt DateTime
error String?
userId String
user User @relation(fields: [userId], references: [id])
@ -87,6 +88,9 @@ model AccessToken {
clientId String
client Client @relation(fields: [clientId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("access_tokens")
}

View File

@ -0,0 +1,237 @@
"use client";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import type { ExtendedAccessToken, ExtendedClient } from "@/types";
import { ArrowLeft } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
async function getClientDetails(id: string) {
const client = await prisma.client.findUnique({
where: { id },
include: {
user: {
select: {
username: true,
email: true,
},
},
accessTokens: {
where: {
expiresAt: {
gt: new Date(),
},
},
include: {
client: {
select: {
name: true,
},
},
user: {
select: {
username: true,
},
},
},
},
},
});
if (!client) {
return null;
}
// 获取最近30天的授权统计
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const authCount = await prisma.accessToken.count({
where: {
clientId: id,
expiresAt: {
gte: thirtyDaysAgo,
},
},
});
return {
...client,
authCount,
} as ExtendedClient;
}
export default async function ClientDetailsPage({
params,
}: {
params: { id: string };
}) {
const user = await getCurrentUser();
if (!user || user.role !== "ADMIN") {
redirect("/dashboard");
}
const client = await getClientDetails(params.id);
if (!client) {
notFound();
}
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/clients">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">{client.name}</h1>
<Badge variant={client.enabled ? "default" : "destructive"}>
{client.enabled ? "启用" : "禁用"}
</Badge>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Client ID
</dt>
<dd className="mt-1 font-mono">{client.clientId}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Client Secret
</dt>
<dd className="mt-1 font-mono">{client.clientSecret}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
</dt>
<dd className="mt-1">{client.redirectUri}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
</dt>
<dd className="mt-1">
{client.home ? (
<a
href={client.home}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{client.home}
</a>
) : (
"-"
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
</dt>
<dd className="mt-1">{client.user.username}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
</dt>
<dd className="mt-1">
{new Date(client.createdAt).toLocaleString()}
</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>使</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
30
</dt>
<dd className="mt-1 text-3xl font-bold">{client.authCount}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
</dt>
<dd className="mt-1 text-3xl font-bold">
{client.accessTokens?.length || 0}
</dd>
</div>
</dl>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>访</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(client.accessTokens || []).map(
(token: ExtendedAccessToken) => (
<TableRow key={token.id}>
<TableCell className="font-mono">{token.id}</TableCell>
<TableCell>
{new Date(token.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{new Date(token.expiresAt).toLocaleString()}
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Search } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
async function getClients(search?: string) {
const where = search
? {
OR: [
{ name: { contains: search } },
{ clientId: { contains: search } },
{ description: { contains: search } },
],
}
: {};
const clients = await prisma.client.findMany({
where,
include: {
user: {
select: {
username: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return clients;
}
export default async function ClientsPage({
searchParams,
}: {
searchParams: { search?: string };
}) {
const user = await getCurrentUser();
if (!user || user.role !== "ADMIN") {
redirect("/dashboard");
}
const clients = await getClients(searchParams.search);
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="flex items-center gap-4">
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<form>
<Input
placeholder="搜索应用..."
name="search"
defaultValue={searchParams.search}
className="pl-8"
/>
</form>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Client ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clients.map((client) => (
<TableRow key={client.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{client.logo && (
<img
src={client.logo}
alt={client.name}
className="h-6 w-6 rounded-full"
/>
)}
{client.name}
</div>
</TableCell>
<TableCell>{client.user.username}</TableCell>
<TableCell className="font-mono">{client.clientId}</TableCell>
<TableCell className="max-w-[200px] truncate">
{client.redirectUri}
</TableCell>
<TableCell>
{new Date(client.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant={client.enabled ? "default" : "destructive"}>
{client.enabled ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell>
<Link href={`/admin/clients/${client.id}`}>
<Button variant="outline" size="sm">
</Button>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import { redirect } from "next/navigation";
import type { ExtendedAccessToken } from "@/types";
import { Search } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
async function getLogs(search?: string) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const logs = await prisma.accessToken.findMany({
where: {
expiresAt: {
gte: thirtyDaysAgo,
},
...(search
? {
OR: [{ clientId: search }, { userId: search }],
}
: {}),
},
include: {
client: {
select: {
name: true,
},
},
user: {
select: {
username: true,
},
},
},
orderBy: [
{
expiresAt: "desc",
},
],
take: 100,
});
return logs as ExtendedAccessToken[];
}
export default async function LogsPage({
searchParams,
}: {
searchParams: { search?: string };
}) {
const user = await getCurrentUser();
if (!user || user.role !== "ADMIN") {
redirect("/dashboard");
}
const logs = await getLogs(searchParams.search);
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription>
30
</CardDescription>
</div>
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<form>
<Input
placeholder="搜索日志..."
name="search"
defaultValue={searchParams.search}
className="pl-8"
/>
</form>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log: ExtendedAccessToken) => (
<TableRow key={log.id}>
<TableCell>
{new Date(log.createdAt).toLocaleString()}
</TableCell>
<TableCell>{log.client?.name || "-"}</TableCell>
<TableCell>{log.user?.username || "-"}</TableCell>
<TableCell className="font-mono">
{log.id.slice(0, 8)}...
</TableCell>
<TableCell>
<Badge
variant={
log.error
? "destructive"
: new Date(log.expiresAt) > new Date()
? "default"
: "secondary"
}
>
{log.error
? "错误"
: new Date(log.expiresAt) > new Date()
? "有效"
: "过期"}
</Badge>
</TableCell>
<TableCell className="max-w-[200px] truncate text-red-500">
{log.error || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,7 +1,10 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Activity, AppWindow, FileText, Users } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@ -9,22 +12,29 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
async function getStats() {
const [userCount, clientCount] = await Promise.all([
prisma.user.count(),
prisma.client.count(),
]);
const [userCount, clientCount, activeTokenCount, recentAuthCount] =
await Promise.all([
prisma.user.count(),
prisma.client.count(),
prisma.accessToken.count({
where: {
expiresAt: {
gt: new Date(),
},
},
}),
prisma.authorization.count({
where: {
createdAt: {
gt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 最近24小时
},
},
}),
]);
const clients = await prisma.client.findMany({
const recentClients = await prisma.client.findMany({
include: {
user: {
select: {
@ -36,13 +46,15 @@ async function getStats() {
orderBy: {
createdAt: "desc",
},
take: 10,
take: 5,
});
return {
userCount,
clientCount,
recentClients: clients,
activeTokenCount,
recentAuthCount,
recentClients,
};
}
@ -56,58 +68,145 @@ export default async function AdminPage() {
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<p className="text-3xl font-bold">{stats.userCount}</p>
<Link href="/admin/users">
<Button className="w-full"></Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="flex items-center gap-2">
<AppWindow className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<p className="text-3xl font-bold">{stats.clientCount}</p>
<Link href="/admin/clients">
<Button className="w-full"></Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-3xl font-bold">{stats.activeTokenCount}</p>
<p className="text-sm text-muted-foreground">访</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-3xl font-bold">{stats.recentAuthCount}</p>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
24
</p>
<Link href="/admin/logs">
<Button variant="outline" size="sm">
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
<Card className="mt-8">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 10 </CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Client ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<div className="mt-8 grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 5 </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stats.recentClients.map((client) => (
<TableRow key={client.id}>
<TableCell className="font-medium">{client.name}</TableCell>
<TableCell>{client.user.username}</TableCell>
<TableCell>
{new Date(client.createdAt).toLocaleString()}
</TableCell>
<TableCell className="font-mono">{client.clientId}</TableCell>
</TableRow>
<div
key={client.id}
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
>
<div className="space-y-1">
<p className="font-medium">{client.name}</p>
<p className="text-sm text-muted-foreground">
{client.user.username} {" "}
{new Date(client.createdAt).toLocaleString()}
</p>
</div>
<Link href={`/admin/clients/${client.id}`}>
<Button variant="outline" size="sm">
</Button>
</Link>
</div>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Node.js </p>
<p className="text-sm text-muted-foreground">
{process.version}
</p>
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-medium"></p>
<p className="text-sm text-muted-foreground">
{process.platform}
</p>
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-medium"></p>
<p className="text-sm text-muted-foreground">{process.arch}</p>
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">ID</p>
<p className="text-sm text-muted-foreground">{process.pid}</p>
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-medium"></p>
<p className="text-sm text-muted-foreground">
{Math.floor(process.uptime())}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,117 @@
import { redirect } from "next/navigation";
import { Search } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
async function getUsers(search?: string) {
const where = search
? {
OR: [
{ username: { contains: search } },
{ email: { contains: search } },
{ name: { contains: search } },
],
}
: {};
const users = await prisma.user.findMany({
where,
orderBy: {
createdAt: "desc",
},
});
return users;
}
export default async function UsersPage({
searchParams,
}: {
searchParams: { search?: string };
}) {
const user = await getCurrentUser();
if (!user || user.role !== "ADMIN") {
redirect("/dashboard");
}
const users = await getUsers(searchParams.search);
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<form>
<Input
placeholder="搜索用户..."
name="search"
defaultValue={searchParams.search}
className="pl-8"
/>
</form>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.name || "-"}</TableCell>
<TableCell>
{user.role === "ADMIN"
? "管理员"
: user.moderator
? "版主"
: "用户"}
</TableCell>
<TableCell>
{new Date(user.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{user.groups?.length ? user.groups.join(", ") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/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) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

33
src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
/// <reference types="next" />
/// <reference types="next/navigation" />
/// <reference types="next/link" />
import {
AccessToken as PrismaAccessToken,
Client as PrismaClient,
User,
} from "@prisma/client";
export interface ExtendedClient extends PrismaClient {
user: {
username: string;
email: string;
};
accessTokens?: ExtendedAccessToken[];
authCount?: number;
enabled?: boolean;
}
export interface ExtendedAccessToken extends PrismaAccessToken {
client?: {
name: string;
};
user?: {
username: string;
};
error?: string | null;
createdAt: Date;
updatedAt: Date;
}
export {};