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>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
// 检查是否为管理员
async function checkAdmin(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isAdmin: true }
})
return user?.isAdmin === true
}
// 获取登录日志
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 })
}
// 检查管理员权限
const isAdmin = await checkAdmin(session.user.id)
if (!isAdmin) {
return NextResponse.json({ error: "无权限访问" }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const limit = parseInt(searchParams.get("limit") || "100")
const page = parseInt(searchParams.get("page") || "1")
const skip = (page - 1) * limit
// 获取登录日志总数
const total = await prisma.loginLog.count()
// 获取登录日志列表
const logs = await prisma.loginLog.findMany({
orderBy: {
createdAt: 'desc',
},
take: limit,
skip,
select: {
id: true,
userId: true,
email: true,
userName: true,
ip: true,
userAgent: true,
success: true,
message: true,
createdAt: true,
}
})
return NextResponse.json({
logs,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
}
})
} catch (error) {
console.error("获取登录日志失败:", error)
return NextResponse.json(
{ error: "获取登录日志失败" },
{ status: 500 }
)
}
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
// 检查是否为管理员
async function checkAdmin(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isAdmin: true }
})
return user?.isAdmin === true
}
// 更新用户状态(启用/停用)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 })
}
// 检查管理员权限
const isAdmin = await checkAdmin(session.user.id)
if (!isAdmin) {
return NextResponse.json({ error: "无权限访问" }, { status: 403 })
}
const { userId } = await params
const body = await req.json()
const { isActive } = body
if (typeof isActive !== "boolean") {
return NextResponse.json({ error: "参数错误" }, { status: 400 })
}
// 不能停用自己
if (userId === session.user.id && !isActive) {
return NextResponse.json({ error: "不能停用自己的账户" }, { status: 400 })
}
// 检查目标用户是否存在
const targetUser = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isAdmin: true }
})
if (!targetUser) {
return NextResponse.json({ error: "用户不存在" }, { status: 404 })
}
// 不能停用其他管理员
if (targetUser.isAdmin && !isActive) {
return NextResponse.json({ error: "不能停用管理员账户" }, { status: 400 })
}
// 更新用户状态
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { isActive },
select: {
id: true,
email: true,
name: true,
isActive: true,
}
})
return NextResponse.json({ user: updatedUser })
} catch (error) {
console.error("更新用户状态失败:", error)
return NextResponse.json(
{ error: "更新用户状态失败" },
{ status: 500 }
)
}
}
+143
View File
@@ -0,0 +1,143 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
// 检查是否为管理员
async function checkAdmin(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isAdmin: true }
})
return user?.isAdmin === true
}
// 获取所有用户及其家族统计信息
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 })
}
// 检查管理员权限
const isAdmin = await checkAdmin(session.user.id)
if (!isAdmin) {
return NextResponse.json({ error: "无权限访问" }, { status: 403 })
}
// 获取所有用户及其拥有的家族树
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
avatar: true,
isAdmin: true,
isActive: true,
createdAt: true,
ownedTrees: {
select: {
id: true,
name: true,
createdAt: true,
_count: {
select: {
members: true,
},
},
// 获取该家族树的协作者
collaborators: {
select: {
role: true,
user: {
select: {
id: true,
name: true,
email: true,
}
}
}
}
},
},
// 获取用户作为协作者参与的家族树
collaborations: {
select: {
role: true,
tree: {
select: {
id: true,
name: true,
owner: {
select: {
id: true,
name: true,
email: true,
}
}
}
}
}
}
},
orderBy: [
{ isAdmin: 'desc' }, // 管理员排在最前面
{ createdAt: 'desc' },
],
})
// 格式化数据
const formattedUsers = users.map((user) => ({
id: user.id,
email: user.email,
name: user.name,
avatar: user.avatar,
isAdmin: user.isAdmin,
isActive: user.isActive,
createdAt: user.createdAt,
treeCount: user.ownedTrees.length,
totalMembers: user.ownedTrees.reduce((sum: number, tree) => sum + tree._count.members, 0),
trees: user.ownedTrees.map((tree) => ({
id: tree.id,
name: tree.name,
createdAt: tree.createdAt,
memberCount: tree._count.members,
// 协作者列表
collaborators: tree.collaborators.map((c) => ({
id: c.user.id,
name: c.user.name,
email: c.user.email,
role: c.role,
})),
})),
// 作为协作者参与的家族
collaboratingTrees: user.collaborations.map((c) => ({
id: c.tree.id,
name: c.tree.name,
role: c.role,
owner: {
id: c.tree.owner.id,
name: c.tree.owner.name,
email: c.tree.owner.email,
}
})),
}))
// 统计总数
const stats = {
totalUsers: users.length,
totalTrees: formattedUsers.reduce((sum, user) => sum + user.treeCount, 0),
totalMembers: formattedUsers.reduce((sum, user) => sum + user.totalMembers, 0),
}
return NextResponse.json({ users: formattedUsers, stats })
} catch (error) {
console.error("获取用户列表失败:", error)
return NextResponse.json(
{ error: "获取用户列表失败" },
{ status: 500 }
)
}
}
+1
View File
@@ -18,6 +18,7 @@ export async function GET(req: NextRequest) {
id: true,
email: true,
name: true,
isAdmin: true,
createdAt: true,
updatedAt: true,
}
+14 -2
View File
@@ -4,7 +4,8 @@ import { SiteHeader } from "@/components/site-header"
import { useFamily } from "@/context/family-context"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Database, User as UserIcon, Settings, User } from "lucide-react"
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Database, User as UserIcon, Settings, User, Users } from "lucide-react"
import Link from "next/link"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { useRef, useState, useEffect } from "react"
import { exportToGedcom, importFromGedcom } from "@/lib/gedcom"
@@ -22,6 +23,7 @@ interface UserProfile {
id: string
email: string
name: string | null
isAdmin: boolean
createdAt: string
updatedAt: string
ownedTreesCount: number
@@ -102,7 +104,17 @@ export default function SettingsPage() {
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 max-w-6xl">
<h1 className="text-3xl font-serif font-bold mb-8"></h1>
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-serif font-bold"></h1>
{userProfile?.isAdmin && (
<Link href="/admin">
<Button variant="outline" className="gap-2">
<Users className="h-4 w-4" />
</Button>
</Link>
)}
</div>
<div className="space-y-6">
{/* 用户信息和密码修改 - 两列布局 */}