Files
chinese-family-tree-2/app/page.tsx
T
freedakgmail 3b0d8255f6 0.0.8.1
2025-11-23 19:38:51 +08:00

663 lines
30 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 type React from "react"
import Link from "next/link"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SiteHeader } from "@/components/site-header"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen, BarChart3, Calculator } from "lucide-react"
import { useFamily } from "@/context/family-context"
import { useMemo, useState, useEffect, useRef } from "react"
import { StatisticsCharts } from "@/components/dashboard/statistics-charts"
import { format } from "date-fns"
import { useSession } from "next-auth/react"
import { useSearchParams } from "next/navigation"
import { CollaboratorDialog } from "@/components/tree/collaborator-dialog"
import { permissions } from "@/lib/permissions"
interface ActivityLog {
id: string
action: string
entityType: string
entityId?: string | null
entityName?: string | null
timestamp: string
user?: {
name?: string | null
email?: string | null
}
}
export default function DashboardPage() {
const { treeData, isLoading, currentTree } = useFamily()
const { data: session } = useSession()
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
// 加载最近的操作日志
useEffect(() => {
if (currentTree?.id && session?.user?.id) {
fetch(`/api/trees/${currentTree.id}/activity-logs?limit=10`)
.then(res => {
if (!res.ok) {
throw new Error('获取活动日志失败')
}
return res.json()
})
.then(data => {
if (data.logs) {
setRecentActivities(data.logs)
}
})
.catch(err => {
console.error('获取活动日志失败:', err)
setRecentActivities([])
})
} else {
setRecentActivities([])
}
}, [currentTree?.id, session?.user?.id])
// 计算统计数据
const stats = useMemo(() => {
const members = Object.values(treeData.members)
const totalMembers = members.length
// 在世和已故人数
const livingMembers = members.filter(m => !m.deathDate).length
const deceasedMembers = members.filter(m => m.deathDate).length
// 性别统计
const maleCount = members.filter(m => m.gender === 'MALE').length
const femaleCount = members.filter(m => m.gender === 'FEMALE').length
// 计算最大代数(处理空数组的情况)
const generations = members.map(m => m.generation || 0)
const maxGeneration = generations.length > 0 ? Math.max(...generations) : 0
// 计算最早出生年份
const birthYears = members
.map(m => m.birthDate ? new Date(m.birthDate).getFullYear() : null)
.filter(y => y !== null) as number[]
const earliestYear = birthYears.length > 0 ? Math.min(...birthYears) : new Date().getFullYear()
const yearsSpan = birthYears.length > 0 ? new Date().getFullYear() - earliestYear : 0
// 计算平均寿命(只统计已故成员)
const deceasedWithAge = members.filter(m => m.birthDate && m.deathDate)
const totalAge = deceasedWithAge.reduce((sum, m) => {
const birthYear = new Date(m.birthDate!).getFullYear()
const deathYear = new Date(m.deathDate!).getFullYear()
return sum + (deathYear - birthYear)
}, 0)
const averageLifespan = deceasedWithAge.length > 0
? Math.round(totalAge / deceasedWithAge.length)
: 0
return {
totalMembers,
livingMembers,
deceasedMembers,
maleCount,
femaleCount,
maxGeneration,
yearsSpan,
earliestYear,
averageLifespan
}
}, [treeData])
// 获取最近的成员(按ID排序,取最新的5个)
const recentMembers = useMemo(() => {
return Object.values(treeData.members)
.sort((a, b) => parseInt(b.id) - parseInt(a.id))
.slice(0, 5)
}, [treeData])
// 获取本月纪念日(生日和忌日)
const monthlyAnniversaries = useMemo(() => {
const currentMonth = new Date().getMonth() + 1
const members = Object.values(treeData.members)
const anniversaries: Array<{
member: any
type: 'birth' | 'death'
date: string
day: number
}> = []
members.forEach(member => {
// 检查生日
if (member.birthDate) {
const birthMonth = new Date(member.birthDate).getMonth() + 1
const birthDay = new Date(member.birthDate).getDate()
if (birthMonth === currentMonth) {
anniversaries.push({
member,
type: 'birth',
date: member.birthDate,
day: birthDay
})
}
}
// 检查忌日
if (member.deathDate) {
const deathMonth = new Date(member.deathDate).getMonth() + 1
const deathDay = new Date(member.deathDate).getDate()
if (deathMonth === currentMonth) {
anniversaries.push({
member,
type: 'death',
date: member.deathDate,
day: deathDay
})
}
}
})
// 按日期排序
return anniversaries.sort((a, b) => a.day - b.day).slice(0, 5)
}, [treeData])
// 计算家族迁徙记录
const locationGroups = useMemo(() => {
const members = Object.values(treeData.members)
const groups: Record<string, any[]> = {}
members.forEach(member => {
if (member.ancestralHome) {
if (!groups[member.ancestralHome]) {
groups[member.ancestralHome] = []
}
groups[member.ancestralHome].push(member)
}
})
return Object.entries(groups).sort((a, b) => b[1].length - a[1].length)
}, [treeData])
// 如果用户没有家族树,显示欢迎页面
if (!isLoading && !currentTree) {
return (
<div className="flex min-h-screen flex-col bg-background font-sans">
<SiteHeader />
<main className="flex-1 flex items-center justify-center px-4 md:px-6 relative">
{/* 背景装饰图片 */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{/* 建筑图片 - 左下 */}
<div className="absolute left-24 top-[60%] -translate-y-1/2 w-[30rem] h-[30rem] opacity-30 rotate-12">
<Image
src="/building.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 树图片 - 右上 */}
<div className="absolute right-24 top-[40%] -translate-y-1/2 w-[30rem] h-[30rem] opacity-30 -rotate-12">
<Image
src="/tree.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
</div>
{/* 内容区域 */}
<div className="max-w-3xl mx-auto text-center space-y-12">
{/* 图标 */}
<div className="inline-flex items-center justify-center w-16 h-16 text-primary/20">
<BookOpen className="h-16 w-16" strokeWidth={1} />
</div>
{/* 标题 */}
<div className="space-y-6">
<h1 className="text-4xl md:text-5xl font-serif font-light text-foreground tracking-wide">
</h1>
{/* 哲理文字 - 滚动显示 */}
<div className="h-48 overflow-hidden relative">
<div className="space-y-8 text-muted-foreground antialiased" style={{ animation: 'scroll 30s linear infinite' }}>
<p className="text-lg md:text-xl font-normal leading-relaxed">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-85">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-70">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-60">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-50">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-40">
</p>
{/* 重复内容实现无缝滚动 */}
<p className="text-lg md:text-xl font-normal leading-relaxed">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-85">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-70">
</p>
</div>
</div>
</div>
{/* 创建按钮 */}
<div className="pt-8">
<Link href="/trees/new">
<Button
size="lg"
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300"
>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</main>
</div>
)
}
return (
<div className="flex min-h-screen flex-col bg-background font-sans">
<SiteHeader />
<main className="flex-1 container mx-auto py-8 px-4 md:px-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-serif font-bold text-foreground">
{currentTree?.name || '家族族谱'}
</h1>
{currentTree?.currentUserRole && (
<span className={`text-xs px-2 py-1 rounded-full border font-sans font-normal translate-y-[2px] ${
currentTree.currentUserRole === 'OWNER'
? 'bg-purple-100 text-purple-700 border-purple-200'
: currentTree.currentUserRole === 'EDITOR'
? 'bg-blue-100 text-blue-700 border-blue-200'
: 'bg-gray-100 text-gray-700 border-gray-200'
}`}>
{currentTree.currentUserRole === 'OWNER' ? '所有者' :
currentTree.currentUserRole === 'EDITOR' ? '编辑者' : '查看者'}
</span>
)}
</div>
<p className="text-muted-foreground mt-1">
{currentTree?.description || '记录家族历史,传承家族文化'}
{stats.maxGeneration > 0 && ` · 第 ${stats.maxGeneration} 代传人`}
</p>
</div>
<div className="flex items-center gap-2">
{session && currentTree?.ownerId === session.user?.id && (
<CollaboratorDialog />
)}
{permissions.canCreate(currentTree?.currentUserRole) && (
<Link href="/members/new">
<Button className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="h-4 w-4" />
</Button>
</Link>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<StatCard
title="家族成员"
value={stats.totalMembers.toString()}
subtitle={`${stats.maleCount} · 女 ${stats.femaleCount}`}
icon={<Users className="h-4 w-4" />}
/>
<StatCard
title="记录年代"
value={`${stats.yearsSpan}`}
subtitle={`始于 ${stats.earliestYear}`}
icon={<Clock className="h-4 w-4" />}
/>
<StatCard
title="繁衍代数"
value={`${stats.maxGeneration}`}
subtitle={`当前最高世代`}
icon={<Network className="h-4 w-4" />}
/>
<StatCard
title="在世 / 已故"
value={`${stats.livingMembers} / ${stats.deceasedMembers}`}
subtitle={`在世率 ${stats.totalMembers > 0 ? Math.round(stats.livingMembers / stats.totalMembers * 100) : 0}%`}
icon={<Users className="h-4 w-4" />}
/>
<StatCard
title="平均寿命"
value={stats.averageLifespan > 0 ? `${stats.averageLifespan}` : '暂无数据'}
subtitle={`已统计 ${stats.deceasedMembers}`}
icon={<Clock className="h-4 w-4" />}
/>
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<Users className="h-4 w-4" />
</div>
</div>
<div className="flex flex-col space-y-1">
<div className="flex items-center justify-center gap-3">
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? '1' : '0'}
</span>
<span className="text-2xl font-bold text-muted-foreground">:</span>
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? (stats.femaleCount / stats.maleCount).toFixed(2) : '0'}
</span>
</div>
<div className="text-center text-xs text-muted-foreground/80 font-medium mt-1">
{stats.maleCount} · {stats.femaleCount}
</div>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
</div>
<Tabs defaultValue="recent" className="w-full">
<div className="flex items-center justify-between mb-4">
<TabsList className="bg-muted/50">
<TabsTrigger value="recent"></TabsTrigger>
<TabsTrigger value="anniversaries"></TabsTrigger>
<TabsTrigger value="statistics"></TabsTrigger>
<TabsTrigger value="migration"></TabsTrigger>
</TabsList>
<Link href="/members">
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1">
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
</div>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivities.length > 0 ? (
recentActivities.map((activity, i) => (
<div key={activity.id} className="flex gap-3 group hover:bg-muted/30 p-2 rounded-lg transition-colors">
<div className="relative flex flex-col items-center">
<div className="h-full w-px bg-border group-last:hidden"></div>
<div className={`absolute top-2 h-2 w-2 rounded-full ring-4 ring-background ${
activity.action === 'create' ? 'bg-green-500' :
activity.action === 'update' ? 'bg-blue-500' :
activity.action === 'delete' ? 'bg-red-500' :
'bg-purple-500'
}`}></div>
</div>
<div className="pb-2 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${
activity.action === 'create' ? 'bg-green-100 text-green-700' :
activity.action === 'update' ? 'bg-blue-100 text-blue-700' :
activity.action === 'delete' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{activity.action === 'create' ? '创建' :
activity.action === 'update' ? '更新' :
activity.action === 'delete' ? '删除' : '导入'}
</span>
<span className="font-medium">{activity.entityName || '未知'}</span>
<span className="text-xs text-muted-foreground">
{format(new Date(activity.timestamp), 'MM-dd HH:mm')}
</span>
</div>
<p className="text-xs text-muted-foreground">
{activity.action === 'create' && '新增了家族成员'}
{activity.action === 'update' && '更新了成员信息'}
{activity.action === 'delete' && '删除了成员'}
{activity.action === 'import' && '导入了家族数据'}
</p>
</div>
</div>
))
) : (
<div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-6">
<Card className="border-none shadow-sm bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary font-serif">
<Calendar className="h-5 w-5" />
/
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
{monthlyAnniversaries.length > 0 ? (
monthlyAnniversaries.map((anniversary, index) => {
const yearsAgo = new Date().getFullYear() - new Date(anniversary.date).getFullYear()
return (
<Link href={`/members/${anniversary.member.id}`} key={`${anniversary.member.id}-${anniversary.type}`}>
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
<span className="text-xs">
{new Date(anniversary.date).getMonth() + 1}
</span>
<span className="font-bold text-lg">{anniversary.day}</span>
</div>
<div className="flex-1">
<p className="font-medium">
{anniversary.member.fullName} {anniversary.type === 'birth' ? '诞辰' : '忌日'}
</p>
<p className="text-sm text-muted-foreground">
{anniversary.member.generation} · {yearsAgo}
{anniversary.type === 'death' && ' · 已故'}
</p>
</div>
</div>
</Link>
)
})
) : (
<div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* 近期纪念日标签页 */}
<TabsContent value="anniversaries" className="mt-0">
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(() => {
const now = new Date()
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
const members = Object.values(treeData.members)
const upcomingEvents: Array<{
member: any
type: 'birth' | 'death'
date: Date
originalDate: string
}> = []
members.forEach(member => {
// 生日
if (member.birthDate) {
const birthDate = new Date(member.birthDate)
const thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
if (thisYearBirth >= now && thisYearBirth <= threeMonthsLater) {
upcomingEvents.push({
member,
type: 'birth',
date: thisYearBirth,
originalDate: member.birthDate
})
}
}
// 忌日
if (member.deathDate) {
const deathDate = new Date(member.deathDate)
const thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
if (thisYearDeath >= now && thisYearDeath <= threeMonthsLater) {
upcomingEvents.push({
member,
type: 'death',
date: thisYearDeath,
originalDate: member.deathDate
})
}
}
})
upcomingEvents.sort((a, b) => a.date.getTime() - b.date.getTime())
return upcomingEvents.length > 0 ? (
upcomingEvents.map((event, index) => {
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
return (
<Link href={`/members/${event.member.id}`} key={`${event.member.id}-${event.type}-${index}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-primary/10 text-primary font-serif">
<span className="text-xs">{event.date.getMonth() + 1}</span>
<span className="font-bold text-lg">{event.date.getDate()}</span>
</div>
<div className="flex-1">
<p className="font-medium text-sm">
{event.member.fullName} {event.type === 'birth' ? '诞辰' : '忌日'}
</p>
<p className="text-xs text-muted-foreground">
{event.member.generation} · {yearsAgo}
{event.type === 'death' && ' · 已故'}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
})
) : (
<div className="col-span-full text-center py-16">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)
})()}
</div>
</CardContent>
</Card>
</TabsContent>
{/* 统计图表标签页 */}
<TabsContent value="statistics" className="mt-0">
<StatisticsCharts members={Object.values(treeData.members)} />
</TabsContent>
{/* 迁徙记录标签页 */}
<TabsContent value="migration" className="mt-0">
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{locationGroups.length > 0 ? (
locationGroups.map(([location, locationMembers]) => (
<div key={location} className="border-l-4 border-primary pl-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-serif font-bold">{location}</h3>
<span className="text-sm text-muted-foreground">
{locationMembers.length}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{locationMembers.map((member: any) => (
<Link href={`/members/${member.id}`} key={member.id}>
<div className="px-3 py-2 bg-muted rounded hover:bg-muted/80 transition-colors">
<p className="text-sm font-medium truncate">{member.fullName}</p>
<p className="text-xs text-muted-foreground"> {member.generation} </p>
</div>
</Link>
))}
</div>
</div>
))
) : (
<div className="text-center py-16">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</div>
)
}
function StatCard({
title,
value,
subtitle,
icon,
}: { title: string; value: string; subtitle: string; icon: React.ReactNode }) {
return (
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
{icon}
</div>
</div>
<div className="flex flex-col space-y-1">
<span className="text-3xl font-serif font-bold text-foreground tracking-tight">{value}</span>
<p className="text-xs text-muted-foreground/80 font-medium">{subtitle}</p>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
)
}