From 98e5563eb0258d745f43b7573b5144ec11055927 Mon Sep 17 00:00:00 2001 From: wood chen Date: Thu, 20 Feb 2025 00:19:29 +0800 Subject: [PATCH] feat: Enhance admin dashboard with comprehensive system overview and statistics --- package-lock.json | 4 +- package.json | 4 +- prisma/schema.prisma | 4 + src/app/(admin)/admin/clients/[id]/page.tsx | 237 ++++++++++++++++++++ src/app/(admin)/admin/clients/page.tsx | 147 ++++++++++++ src/app/(admin)/admin/logs/page.tsx | 152 +++++++++++++ src/app/(admin)/admin/page.tsx | 199 +++++++++++----- src/app/(admin)/admin/users/page.tsx | 117 ++++++++++ src/components/ui/badge.tsx | 36 +++ src/types/index.d.ts | 33 +++ 10 files changed, 879 insertions(+), 54 deletions(-) create mode 100644 src/app/(admin)/admin/clients/[id]/page.tsx create mode 100644 src/app/(admin)/admin/clients/page.tsx create mode 100644 src/app/(admin)/admin/logs/page.tsx create mode 100644 src/app/(admin)/admin/users/page.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/types/index.d.ts diff --git a/package-lock.json b/package-lock.json index 088719e..3493f92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1415b3e..6259b57 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f68d0d8..a8516a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/(admin)/admin/clients/[id]/page.tsx b/src/app/(admin)/admin/clients/[id]/page.tsx new file mode 100644 index 0000000..fa3f66b --- /dev/null +++ b/src/app/(admin)/admin/clients/[id]/page.tsx @@ -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 ( +
+
+
+ + + +

{client.name}

+ + {client.enabled ? "启用" : "禁用"} + +
+
+ +
+ + + 应用信息 + 应用的基本信息 + + +
+
+
+ Client ID +
+
{client.clientId}
+
+
+
+ Client Secret +
+
{client.clientSecret}
+
+
+
+ 回调地址 +
+
{client.redirectUri}
+
+
+
+ 应用主页 +
+
+ {client.home ? ( + + {client.home} + + ) : ( + "-" + )} +
+
+
+
+ 创建者 +
+
{client.user.username}
+
+
+
+ 创建时间 +
+
+ {new Date(client.createdAt).toLocaleString()} +
+
+
+
+
+ + + + 使用统计 + 应用的使用情况统计 + + +
+
+
+ 最近30天授权次数 +
+
{client.authCount}
+
+
+
+ 当前活跃令牌 +
+
+ {client.accessTokens?.length || 0} +
+
+
+
+
+ + + + 活跃令牌 + 当前有效的访问令牌 + + + + + + 令牌ID + 创建时间 + 过期时间 + + + + {(client.accessTokens || []).map( + (token: ExtendedAccessToken) => ( + + {token.id} + + {new Date(token.createdAt).toLocaleString()} + + + {new Date(token.expiresAt).toLocaleString()} + + + ), + )} + +
+
+
+
+
+ ); +} diff --git a/src/app/(admin)/admin/clients/page.tsx b/src/app/(admin)/admin/clients/page.tsx new file mode 100644 index 0000000..11a8d1f --- /dev/null +++ b/src/app/(admin)/admin/clients/page.tsx @@ -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 ( +
+ + +
+
+ 应用管理 + 查看和管理系统中的所有应用 +
+
+
+ +
+ +
+
+
+
+
+ + + + + 应用名称 + 创建者 + Client ID + 回调地址 + 创建时间 + 状态 + 操作 + + + + {clients.map((client) => ( + + +
+ {client.logo && ( + {client.name} + )} + {client.name} +
+
+ {client.user.username} + {client.clientId} + + {client.redirectUri} + + + {new Date(client.createdAt).toLocaleString()} + + + + {client.enabled ? "启用" : "禁用"} + + + + + + + +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/app/(admin)/admin/logs/page.tsx b/src/app/(admin)/admin/logs/page.tsx new file mode 100644 index 0000000..615d690 --- /dev/null +++ b/src/app/(admin)/admin/logs/page.tsx @@ -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 ( +
+ + +
+
+ 系统日志 + + 显示最近30天的授权记录和错误日志 + +
+
+ +
+ +
+
+
+
+ + + + + 时间 + 应用 + 用户 + 令牌 + 状态 + 错误信息 + + + + {logs.map((log: ExtendedAccessToken) => ( + + + {new Date(log.createdAt).toLocaleString()} + + {log.client?.name || "-"} + {log.user?.username || "-"} + + {log.id.slice(0, 8)}... + + + new Date() + ? "default" + : "secondary" + } + > + {log.error + ? "错误" + : new Date(log.expiresAt) > new Date() + ? "有效" + : "过期"} + + + + {log.error || "-"} + + + ))} + +
+
+
+
+ ); +} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 5817558..0ccb823 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -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 (
-
+
- 用户统计 - 系统中的总用户数 + + + 用户管理 + + 管理系统中的用户账号 - +

{stats.userCount}

+ + +
- 应用统计 - 系统中的总应用数 + + + 应用管理 + + 管理系统中的应用 - +

{stats.clientCount}

+ + + +
+
+ + + + + + 系统状态 + + 查看系统运行状态 + + +

{stats.activeTokenCount}

+

活跃访问令牌数

+
+
+ + + + + + 系统日志 + + 查看系统日志记录 + + +

{stats.recentAuthCount}

+
+

+ 24小时内新增授权数 +

+ + + +
- - - 最近创建的应用 - 显示最近创建的 10 个应用 - - - - - - 应用名称 - 创建者 - 创建时间 - Client ID - - - +
+ + + 最近创建的应用 + 显示最近创建的 5 个应用 + + +
{stats.recentClients.map((client) => ( - - {client.name} - {client.user.username} - - {new Date(client.createdAt).toLocaleString()} - - {client.clientId} - +
+
+

{client.name}

+

+ 由 {client.user.username} 创建于{" "} + {new Date(client.createdAt).toLocaleString()} +

+
+ + + +
))} - -
-
-
+
+ + + + + + 系统信息 + 显示系统运行状态信息 + + +
+
+

Node.js 版本

+

+ {process.version} +

+
+
+

系统平台

+

+ {process.platform} +

+
+
+

系统架构

+

{process.arch}

+
+
+

进程ID

+

{process.pid}

+
+
+

运行时长

+

+ {Math.floor(process.uptime())} 秒 +

+
+
+
+
+
); } diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..78c3b26 --- /dev/null +++ b/src/app/(admin)/admin/users/page.tsx @@ -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 ( +
+ + +
+
+ 用户管理 + 查看和管理系统中的所有用户 +
+
+ +
+ +
+
+
+
+ + + + + 用户名 + 邮箱 + 昵称 + 角色 + 创建时间 + 用户组 + + + + {users.map((user) => ( + + {user.username} + {user.email} + {user.name || "-"} + + {user.role === "ADMIN" + ? "管理员" + : user.moderator + ? "版主" + : "用户"} + + + {new Date(user.createdAt).toLocaleString()} + + + {user.groups?.length ? user.groups.join(", ") : "-"} + + + ))} + +
+
+
+
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..d3d5d60 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 0000000..af6a9ee --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,33 @@ +/// +/// +/// + +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 {};