674 lines
30 KiB
TypeScript
674 lines
30 KiB
TypeScript
"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 (session?.user?.id) {
|
||
const hasVisited = localStorage.getItem(`user_${session.user.id}_visited`)
|
||
if (!hasVisited) {
|
||
localStorage.setItem(`user_${session.user.id}_visited`, 'true')
|
||
window.location.href = '/help'
|
||
}
|
||
}
|
||
}, [session?.user?.id])
|
||
|
||
// 加载最近的操作日志
|
||
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 animate-scroll">
|
||
<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>
|
||
)
|
||
}
|