mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-18 05:51: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": {
|
||||
"@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",
|
||||
|
@ -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",
|
||||
|
@ -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")
|
||||
}
|
||||
|
||||
|
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 { 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>
|
||||
);
|
||||
}
|
||||
|
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