0.2.0.0
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user