mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-19 06:21:55 +08:00
feat: Enhance admin dashboard with comprehensive system overview and statistics
This commit is contained in:
parent
dcf36824ed
commit
98e5563eb0
4
package-lock.json
generated
4
package-lock.json
generated
@ -36,8 +36,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.17.19",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.7",
|
"eslint-config-next": "14.2.7",
|
||||||
|
@ -43,8 +43,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.17.19",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.7",
|
"eslint-config-next": "14.2.7",
|
||||||
|
@ -80,6 +80,7 @@ model AccessToken {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
token String @unique
|
token String @unique
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
error String?
|
||||||
|
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
@ -87,6 +88,9 @@ model AccessToken {
|
|||||||
clientId String
|
clientId String
|
||||||
client Client @relation(fields: [clientId], references: [id])
|
client Client @relation(fields: [clientId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@map("access_tokens")
|
@@map("access_tokens")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
237
src/app/(admin)/admin/clients/[id]/page.tsx
Normal file
237
src/app/(admin)/admin/clients/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
147
src/app/(admin)/admin/clients/page.tsx
Normal file
147
src/app/(admin)/admin/clients/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
152
src/app/(admin)/admin/logs/page.tsx
Normal file
152
src/app/(admin)/admin/logs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,10 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Activity, AppWindow, FileText, Users } from "lucide-react";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -9,22 +12,29 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
async function getStats() {
|
async function getStats() {
|
||||||
const [userCount, clientCount] = await Promise.all([
|
const [userCount, clientCount, activeTokenCount, recentAuthCount] =
|
||||||
prisma.user.count(),
|
await Promise.all([
|
||||||
prisma.client.count(),
|
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: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
@ -36,13 +46,15 @@ async function getStats() {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: "desc",
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
take: 10,
|
take: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userCount,
|
userCount,
|
||||||
clientCount,
|
clientCount,
|
||||||
recentClients: clients,
|
activeTokenCount,
|
||||||
|
recentAuthCount,
|
||||||
|
recentClients,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,58 +68,145 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>用户统计</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>系统中的总用户数</CardDescription>
|
<Users className="h-5 w-5" />
|
||||||
|
用户管理
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>管理系统中的用户账号</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<p className="text-3xl font-bold">{stats.userCount}</p>
|
<p className="text-3xl font-bold">{stats.userCount}</p>
|
||||||
|
<Link href="/admin/users">
|
||||||
|
<Button className="w-full">查看用户</Button>
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>应用统计</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>系统中的总应用数</CardDescription>
|
<AppWindow className="h-5 w-5" />
|
||||||
|
应用管理
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>管理系统中的应用</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<p className="text-3xl font-bold">{stats.clientCount}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mt-8">
|
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>最近创建的应用</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>显示最近创建的 10 个应用</CardDescription>
|
<CardTitle>最近创建的应用</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>显示最近创建的 5 个应用</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<Table>
|
<CardContent>
|
||||||
<TableHeader>
|
<div className="space-y-4">
|
||||||
<TableRow>
|
|
||||||
<TableHead>应用名称</TableHead>
|
|
||||||
<TableHead>创建者</TableHead>
|
|
||||||
<TableHead>创建时间</TableHead>
|
|
||||||
<TableHead>Client ID</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{stats.recentClients.map((client) => (
|
{stats.recentClients.map((client) => (
|
||||||
<TableRow key={client.id}>
|
<div
|
||||||
<TableCell className="font-medium">{client.name}</TableCell>
|
key={client.id}
|
||||||
<TableCell>{client.user.username}</TableCell>
|
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||||
<TableCell>
|
>
|
||||||
{new Date(client.createdAt).toLocaleString()}
|
<div className="space-y-1">
|
||||||
</TableCell>
|
<p className="font-medium">{client.name}</p>
|
||||||
<TableCell className="font-mono">{client.clientId}</TableCell>
|
<p className="text-sm text-muted-foreground">
|
||||||
</TableRow>
|
由 {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>
|
</div>
|
||||||
</Table>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
117
src/app/(admin)/admin/users/page.tsx
Normal file
117
src/app/(admin)/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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
33
src/types/index.d.ts
vendored
Normal 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 {};
|
Loading…
x
Reference in New Issue
Block a user