536 lines
21 KiB
TypeScript
536 lines
21 KiB
TypeScript
"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>
|
||
)
|
||
}
|