This commit is contained in:
freedakgmail
2025-11-29 21:23:45 +08:00
parent 7442939ade
commit dca082e0a3
189 changed files with 1491 additions and 486 deletions
+535
View File
@@ -0,0 +1,535 @@
"use client"
import React, { useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import { useRouter } from "next/navigation"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
import { Users, TreePine, UserPlus, ArrowLeft, ChevronDown, ChevronRight, Shield, Ban, Check, LogIn } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
interface Collaborator {
id: string
name: string | null
email: string
role: string
}
interface TreeOwner {
id: string
name: string | null
email: string
}
interface TreeInfo {
id: string
name: string
createdAt: string
memberCount: number
collaborators: Collaborator[]
}
interface CollaboratingTree {
id: string
name: string
role: string
owner: TreeOwner
}
interface UserInfo {
id: string
email: string
name: string | null
avatar: string | null
isAdmin: boolean
isActive: boolean
createdAt: string
treeCount: number
totalMembers: number
trees: TreeInfo[]
collaboratingTrees: CollaboratingTree[]
}
interface Stats {
totalUsers: number
totalTrees: number
totalMembers: number
}
interface LoginLog {
id: string
userId: string
email: string
userName: string | null
ip: string | null
userAgent: string | null
success: boolean
message: string | null
createdAt: string
}
export default function AdminPage() {
const { data: session, status } = useSession()
const router = useRouter()
const [users, setUsers] = useState<UserInfo[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
const [updating, setUpdating] = useState<string | null>(null)
const [loginLogs, setLoginLogs] = useState<LoginLog[]>([])
const [logsLoading, setLogsLoading] = useState(false)
useEffect(() => {
if (status === "unauthenticated") {
router.push("/auth/signin")
return
}
if (status === "authenticated") {
fetchUsers()
fetchLoginLogs()
}
}, [status, router])
const fetchUsers = async () => {
try {
setLoading(true)
const response = await fetch("/api/admin/users")
if (!response.ok) {
throw new Error("获取数据失败")
}
const data = await response.json()
setUsers(data.users)
setStats(data.stats)
} catch (err) {
setError(err instanceof Error ? err.message : "未知错误")
} finally {
setLoading(false)
}
}
const fetchLoginLogs = async () => {
try {
setLogsLoading(true)
const response = await fetch("/api/admin/login-logs?limit=100")
if (!response.ok) {
// 如果获取失败,设置为空数组,不报错
setLoginLogs([])
return
}
const data = await response.json()
setLoginLogs(data.logs || [])
} catch (err) {
console.error("获取登录日志失败:", err)
setLoginLogs([])
} finally {
setLogsLoading(false)
}
}
const toggleUserExpand = (userId: string) => {
setExpandedUsers((prev) => {
const newSet = new Set(prev)
if (newSet.has(userId)) {
newSet.delete(userId)
} else {
newSet.add(userId)
}
return newSet
})
}
const formatDate = (dateString: string) => {
return format(new Date(dateString), "yyyy年MM月dd日 HH:mm", { locale: zhCN })
}
const toggleUserStatus = async (userId: string, currentStatus: boolean) => {
try {
setUpdating(userId)
const response = await fetch(`/api/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: !currentStatus }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "操作失败")
}
// 更新本地状态
setUsers((prev) =>
prev.map((user) =>
user.id === userId ? { ...user, isActive: !currentStatus } : user
)
)
} catch (err) {
alert(err instanceof Error ? err.message : "操作失败")
} finally {
setUpdating(null)
}
}
if (status === "loading" || loading) {
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-7xl mx-auto space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-96" />
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-7xl mx-auto">
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/")}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-2xl font-bold"></h1>
</div>
</div>
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalUsers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TreePine className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalTrees}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<UserPlus className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalMembers}</div>
</CardContent>
</Card>
</div>
)}
{/* Tabs */}
<Tabs defaultValue="users" className="space-y-4">
<TabsList>
<TabsTrigger value="users" className="gap-2">
<Users className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="logs" className="gap-2">
<LogIn className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 用户列表 Tab */}
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<React.Fragment key={user.id}>
<TableRow
className="cursor-pointer"
onClick={() => toggleUserExpand(user.id)}
>
<TableCell>
{(user.trees.length > 0 || user.collaboratingTrees.length > 0) && (
expandedUsers.has(user.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)
)}
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{user.name || "未设置"}
{user.isAdmin && (
<span title="管理员">
<Shield className="h-4 w-4 text-amber-500" />
</span>
)}
</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-center">
{user.isActive ? (
<span className="inline-flex items-center gap-1 text-green-600">
<Check className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500">
<Ban className="h-4 w-4" />
</span>
)}
</TableCell>
<TableCell className="text-center">{user.treeCount}</TableCell>
<TableCell className="text-center">{user.totalMembers}</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell className="text-center">
{!user.isAdmin && (
<Button
variant={user.isActive ? "destructive" : "default"}
size="sm"
disabled={updating === user.id}
onClick={(e) => {
e.stopPropagation()
toggleUserStatus(user.id, user.isActive)
}}
>
{updating === user.id
? "处理中..."
: user.isActive
? "停用"
: "启用"}
</Button>
)}
</TableCell>
</TableRow>
{/* 展开的家族列表和协作信息 */}
{expandedUsers.has(user.id) && (user.trees.length > 0 || user.collaboratingTrees.length > 0) && (
<TableRow key={`${user.id}-trees`}>
<TableCell colSpan={8} className="bg-muted/50 p-0">
<div className="px-8 py-3 space-y-4">
{/* 创建的家族 */}
{user.trees.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2"></h4>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{user.trees.map((tree) => (
<TableRow key={tree.id}>
<TableCell className="font-medium">
{tree.name}
</TableCell>
<TableCell className="text-center">
{tree.memberCount}
</TableCell>
<TableCell>
{tree.collaborators.length > 0 ? (
<div className="flex flex-wrap gap-1">
{tree.collaborators.map((c) => (
<span
key={c.id}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700"
title={c.email}
>
{c.name || c.email}
<span className="ml-1 text-blue-500">
({c.role === 'EDITOR' ? '编辑' : '查看'})
</span>
</span>
))}
</div>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
<TableCell>{formatDate(tree.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 协作的家族 */}
{user.collaboratingTrees.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2"></h4>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{user.collaboratingTrees.map((tree) => (
<TableRow key={tree.id}>
<TableCell className="font-medium">
{tree.name}
</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${
tree.role === 'EDITOR'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}>
{tree.role === 'EDITOR' ? '编辑者' : '查看者'}
</span>
</TableCell>
<TableCell>
<span title={tree.owner.email}>
{tree.owner.name || tree.owner.email}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* 登录日志 Tab */}
<TabsContent value="logs">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{logsLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loginLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="font-medium">
{log.userName || "未设置"}
</TableCell>
<TableCell>{log.email}</TableCell>
<TableCell className="text-center">
{log.success ? (
<span className="inline-flex items-center gap-1 text-green-600">
<Check className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500">
<Ban className="h-4 w-4" />
</span>
)}
</TableCell>
<TableCell>{formatDate(log.createdAt)}</TableCell>
</TableRow>
))}
{loginLogs.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
)
}