Files
chinese-family-tree-2/app/admin/page.tsx
T
freedakgmail dca082e0a3 0.2.0.0
2025-11-29 21:23:45 +08:00

536 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}