Files
chinese-family-tree-2/app/page.tsx
T
freedakgmail 0281885f73 0.3.0.4
2025-11-30 12:04:05 +08:00

1072 lines
52 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 dynamic from "next/dynamic"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
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, Loader2, Images } from "lucide-react"
import { useFamily } from "@/context/family-context"
import { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
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"
import { isInCurrentMonth, solar2lunar, lunar2solar } from "@/lib/lunar-calendar"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
// 动态导入统计图表组件(减少初始加载体积)
const StatisticsCharts = dynamic(
() => import("@/components/dashboard/statistics-charts").then(mod => ({ default: mod.StatisticsCharts })),
{
ssr: false,
loading: () => (
<div className="w-full h-64 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
)
}
)
interface ActivityLog {
id: string
action: string
entityType: string
entityId?: string | null
entityName?: string | null
changes?: any
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[]>([])
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
const router = useRouter()
// 首次登录跳转到使用帮助
useEffect(() => {
if (!session?.user?.id) return
if (session.user.hasSeenHelp) return
router.replace('/help')
}, [router, session?.user?.id, session?.user?.hasSeenHelp])
// 加载最近的操作日志
useEffect(() => {
// 只有在有家族树且已登录时才获取活动日志
if (!currentTree?.id || !session?.user?.id) {
setRecentActivities([])
return
}
const controller = new AbortController()
fetch(`/api/trees/${currentTree.id}/activity-logs?limit=10`, {
signal: controller.signal
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then(data => {
if (data.logs && Array.isArray(data.logs)) {
setRecentActivities(data.logs)
}
})
.catch(err => {
// 忽略 abort 错误
if (err.name !== 'AbortError') {
console.error('获取活动日志失败:', err)
}
setRecentActivities([])
})
return () => controller.abort()
}, [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 allPhotos = useMemo(() => {
const photos: Array<{
url: string
caption?: string
uploadedAt: string
memberId: string
memberName: string
isDead: boolean
}> = []
Object.values(treeData.members).forEach(member => {
// 优先使用新格式 photos
if (member.photos && member.photos.length > 0) {
member.photos.forEach(photo => {
photos.push({
url: photo.url,
caption: photo.caption,
uploadedAt: photo.uploadedAt,
memberId: member.id,
memberName: member.fullName,
isDead: !!member.deathDate
})
})
} else if (member.photoIds && member.photoIds.length > 0) {
// 兼容旧格式 photoIds
member.photoIds.forEach(url => {
photos.push({
url,
uploadedAt: member.updatedAt || new Date().toISOString(),
memberId: member.id,
memberName: member.fullName,
isDead: !!member.deathDate
})
})
}
})
// 按上传时间倒序排列(最新的在前)
return photos.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
}, [treeData])
// 获取本月纪念日(生日和忌日)- 支持农历
const monthlyAnniversaries = useMemo(() => {
const members = Object.values(treeData.members)
const anniversaries: Array<{
member: any
type: 'birth' | 'death'
date: string
day: number
month: number
isLunar: boolean
lunarDisplay?: string
}> = []
members.forEach(member => {
// 检查生日
if (member.birthDate) {
const isLunar = member.isLunarDate || false
const result = isInCurrentMonth(member.birthDate, isLunar)
if (result) {
let lunarDisplay = undefined
if (isLunar) {
const lunar = solar2lunar(new Date(member.birthDate))
if (lunar) {
lunarDisplay = `${lunar.monthName}${lunar.dayName}`
}
}
anniversaries.push({
member,
type: 'birth',
date: member.birthDate,
day: result.day,
month: result.month,
isLunar,
lunarDisplay
})
}
}
// 检查忌日
if (member.deathDate) {
const isLunar = member.isLunarDate || false
const result = isInCurrentMonth(member.deathDate, isLunar)
if (result) {
let lunarDisplay = undefined
if (isLunar) {
const lunar = solar2lunar(new Date(member.deathDate))
if (lunar) {
lunarDisplay = `${lunar.monthName}${lunar.dayName}`
}
}
anniversaries.push({
member,
type: 'death',
date: member.deathDate,
day: result.day,
month: result.month,
isLunar,
lunarDisplay
})
}
}
})
// 按日期排序
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 -mt-24">
{/* 背景装饰图片 */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{/* 文字图片 - 左上角,轻微向右倾斜 */}
<div className="absolute left-32 top-[20%] w-[20rem] h-[20rem] opacity-20 rotate-3">
<Image
src="/wxz.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 树图片1 - 右上角,轻微向左倾斜,对称布局 */}
<div className="absolute right-32 top-[20%] w-[20rem] h-[20rem] opacity-20 -rotate-3">
<Image
src="/tree1.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 建筑图片 - 左下角,向右倾斜,营造动感 */}
<div className="absolute left-24 bottom-[8%] w-[28rem] h-[28rem] opacity-15 rotate-6">
<Image
src="/building.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 树图片 - 右下角,向左倾斜,与建筑对称 */}
<div className="absolute right-24 bottom-[8%] w-[28rem] h-[28rem] opacity-15 -rotate-6">
<Image
src="/tree.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
</div>
{/* 内容区域 */}
<div className="max-w-3xl mx-auto text-center space-y-12">
{/* Slogan */}
<p className="text-2xl md:text-3xl text-foreground font-serif font-medium tracking-wider">
·
</p>
{/* 图标 */}
<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-serif font-normal leading-relaxed tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-85 tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-70 tracking-wide">
·
</p>
{/* 重复内容实现无缝滚动 */}
<p className="text-lg md:text-xl font-serif font-normal leading-relaxed tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-85 tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-70 tracking-wide">
·
</p>
</div>
</div>
</div>
{/* 创建按钮 */}
<div className="pt-8">
<Link href="/trees/new">
<Button
size="lg"
className="gap-3 px-8 py-6 text-lg font-serif bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 shadow-lg hover:shadow-xl"
>
<ArrowRight className="h-5 w-5" />
</Button>
</Link>
</div>
</div>
</main>
</div>
)
}
return (
<div className="flex h-screen flex-col bg-background font-sans overflow-hidden">
<SiteHeader />
<main className="flex-1 container mx-auto py-8 px-4 md:px-6 flex flex-col overflow-hidden">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-8 flex-shrink-0">
<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={currentTree?.id ? `/members/new?treeId=${currentTree.id}` : '/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-3 gap-4 mb-8 flex-shrink-0">
{/* 第一个卡片:家族成员 + 在世/已故 */}
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<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 cursor-pointer">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<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-5 w-5" />
</div>
</div>
<div className="flex flex-col space-y-1.5">
<span className="text-3xl font-serif font-bold text-foreground tracking-tight">{stats.totalMembers} </span>
<div className="flex items-center gap-3 text-sm text-muted-foreground/80 font-medium">
<span className="text-green-600"> {stats.livingMembers}</span>
<span className="text-muted-foreground">·</span>
<span className="text-gray-600"> {stats.deceasedMembers}</span>
</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>
</Link>
{/* 第二个卡片:繁衍代数 + 记录年代 + 平均寿命 */}
<Link href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : '#'}>
<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 cursor-pointer">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<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">
<Clock className="h-5 w-5" />
</div>
</div>
<div className="flex flex-col space-y-1.5">
<span className="text-3xl font-serif font-bold text-foreground tracking-tight">{stats.maxGeneration} </span>
<div className="flex items-center gap-3 text-sm text-muted-foreground/80 font-medium">
<span> {stats.yearsSpan} </span>
<span className="text-muted-foreground">·</span>
<span>寿 {stats.averageLifespan > 0 ? `${stats.averageLifespan}` : '-'}</span>
</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>
</Link>
{/* 第三个卡片:性别比例 */}
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<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 cursor-pointer">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<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-5 w-5" />
</div>
</div>
<div className="flex flex-col space-y-1.5">
<div className="flex items-center justify-center gap-2.5">
<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-sm text-muted-foreground/80 font-medium">
{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>
</Link>
</div>
<Tabs defaultValue="photos" className="w-full flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<TabsList className="bg-muted/50">
<TabsTrigger value="photos"></TabsTrigger>
<TabsTrigger value="recent"></TabsTrigger>
<TabsTrigger value="anniversaries"></TabsTrigger>
<TabsTrigger value="statistics"></TabsTrigger>
<TabsTrigger value="migration"></TabsTrigger>
</TabsList>
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '/members'}>
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1">
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
</div>
{/* 照片展示标签页 */}
<TabsContent value="photos" className="mt-0 flex-1 overflow-y-auto">
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif flex items-center gap-2">
<Images className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{allPhotos.length > 0 ? (
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
{allPhotos.map((photo, index) => (
<div
key={`${photo.memberId}-${index}`}
className="break-inside-avoid group"
>
<div className="rounded-lg overflow-hidden bg-card shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 border border-border/50">
{/* 照片区域 - 点击放大 */}
<div
className="relative cursor-zoom-in"
onClick={() => setSelectedPhoto(photo.url)}
>
<img
src={photo.url}
alt={photo.caption || `${photo.memberName}的照片`}
className="w-full h-auto object-cover"
loading="lazy"
/>
</div>
{/* 底部显示信息 */}
<div className="px-3 py-2 bg-card border-t border-border/30 space-y-1">
{/* 照片说明 */}
<div className="text-xs line-clamp-2">
{photo.caption ? (
<span className="text-foreground">{photo.caption}</span>
) : (
<span className="text-muted-foreground/70"></span>
)}
</div>
{/* 分享人和时间 */}
<div className="flex items-center justify-between gap-1.5 text-xs">
<div className="flex items-center gap-1">
<span className="text-muted-foreground"></span>
<Link
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
className="hover:text-primary hover:underline"
>
<MemberNameWithStatus
name={photo.memberName}
isDead={photo.isDead}
className="text-foreground font-medium"
/>
</Link>
</div>
<span className="text-muted-foreground/70 text-[10px]">
{format(new Date(photo.uploadedAt), 'yyyy-MM-dd')}
</span>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16">
<Images className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground"></p>
<p className="text-sm text-muted-foreground/70 mt-1"></p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="recent" className="mt-0 flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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}${currentTree?.id ? `?treeId=${currentTree.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">
{anniversary.isLunar && anniversary.lunarDisplay ? (
// 农历日期框 - 红色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border-2 border-red-200/50 text-red-800 font-serif shadow-sm">
<span className="text-[10px] leading-tight text-center px-1">
{anniversary.lunarDisplay.replace('月', '')}
</span>
</div>
) : (
// 公历日期框 - 蓝色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border-2 border-blue-200/50 text-blue-800 font-serif shadow-sm">
<span className="text-xs font-medium">
{anniversary.month}
</span>
<span className="font-bold text-xl leading-tight">{anniversary.day}</span>
</div>
)}
<div className="flex-1">
<p className="font-medium flex items-center gap-2">
<MemberNameWithStatus
name={anniversary.member.fullName}
isDead={!!anniversary.member.deathDate}
/> {anniversary.type === 'birth' ? '诞辰' : '忌日'}
{anniversary.isLunar && (
<span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
</span>
)}
</p>
<p className="text-sm text-muted-foreground">
{anniversary.member.generation} · {yearsAgo}
{anniversary.type === 'death' && ' · 已故'}
{anniversary.isLunar && anniversary.lunarDisplay && (
<span className="ml-1">· {anniversary.lunarDisplay}</span>
)}
</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>
<Card className="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>
{activity.entityId && activity.entityType === 'MEMBER' ? (
(() => {
const member = treeData.members[activity.entityId!]
if (member) {
return (
<MemberNameWithStatus
name={activity.entityName || member.fullName || '未知'}
isDead={!!member.deathDate}
memberId={activity.entityId}
treeId={currentTree?.id}
className="font-medium"
/>
)
}
// 成员不存在(可能已删除),不显示状态点,但保留链接(如果不是删除操作)
return activity.action === 'DELETE' ? (
<span className="font-medium">{activity.entityName || '未知'}</span>
) : (
<Link
href={`/members/${activity.entityId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
className="font-medium hover:text-primary hover:underline transition-colors"
>
{activity.entityName || '未知'}
</Link>
)
})()
) : (
<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>
{activity.action === 'UPDATE' && activity.changes ? (
<div className="text-xs text-muted-foreground space-y-0.5">
{(() => {
const changes = activity.changes as Record<string, { old: any, new: any }>
const fieldLabels: Record<string, string> = {
fullName: '姓名', surname: '姓氏', givenName: '名字', gender: '性别',
birthDate: '出生日期', deathDate: '去世日期', birthPlace: '出生地',
ancestralHome: '祖籍', generation: '世代', generationName: '字辈',
courtesyName: '字', artName: '号', posthumousName: '谥号', rank: '排行',
bio: '简介', phone: '手机', telephone: '电话', email: '邮箱',
address: '地址', photoIds: '照片', spouseIds: '配偶', childrenIds: '子女',
motherId: '母亲', fatherId: '父亲', isFounder: '始祖',
isLunarDate: '农历日期', burialPlace: '安葬地', tags: '标签',
}
const entries = Object.entries(changes)
.filter(([key]) => fieldLabels[key])
.slice(0, 2) // 只显示前2个变更
return entries.length > 0 ? (
<>
{entries.map(([key]) => (
<div key={key}> {fieldLabels[key]}</div>
))}
{Object.keys(changes).length > 2 && (
<div className="italic"> {Object.keys(changes).length} </div>
)}
</>
) : <div></div>
})()}
</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>
</TabsContent>
{/* 近期纪念日标签页 */}
<TabsContent value="anniversaries" className="mt-0 flex-1 overflow-y-auto">
<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
isLunar: boolean
lunarDisplay?: string
month: number
day: number
}> = []
members.forEach(member => {
// 生日
if (member.birthDate) {
const birthDate = new Date(member.birthDate)
let thisYearBirth: Date
let lunarDisplay: string | undefined
// 检查是否按农历计算
if (member.isLunarDate) {
// 农历生日:计算今年对应的公历日期
const lunarInfo = solar2lunar(birthDate)
if (lunarInfo) {
const thisYearLunar = lunar2solar(
now.getFullYear(),
lunarInfo.lunarMonth,
lunarInfo.lunarDay,
lunarInfo.isLeap
)
if (thisYearLunar) {
thisYearBirth = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
} else {
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
} else {
// 公历生日
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
if (thisYearBirth >= now && thisYearBirth <= threeMonthsLater) {
upcomingEvents.push({
member,
type: 'birth',
date: thisYearBirth,
originalDate: member.birthDate,
isLunar: member.isLunarDate || false,
lunarDisplay,
month: thisYearBirth.getMonth() + 1,
day: thisYearBirth.getDate()
})
}
}
// 忌日
if (member.deathDate) {
const deathDate = new Date(member.deathDate)
let thisYearDeath: Date
let lunarDisplay: string | undefined
// 检查是否按农历计算
if (member.isLunarDate) {
// 农历忌日:计算今年对应的公历日期
const lunarInfo = solar2lunar(deathDate)
if (lunarInfo) {
const thisYearLunar = lunar2solar(
now.getFullYear(),
lunarInfo.lunarMonth,
lunarInfo.lunarDay,
lunarInfo.isLeap
)
if (thisYearLunar) {
thisYearDeath = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
} else {
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
} else {
// 公历忌日
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
if (thisYearDeath >= now && thisYearDeath <= threeMonthsLater) {
upcomingEvents.push({
member,
type: 'death',
date: thisYearDeath,
originalDate: member.deathDate,
isLunar: member.isLunarDate || false,
lunarDisplay,
month: thisYearDeath.getMonth() + 1,
day: thisYearDeath.getDate()
})
}
}
})
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}${currentTree?.id ? `?treeId=${currentTree.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">
{event.isLunar && event.lunarDisplay ? (
// 农历日期框 - 红色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border-2 border-red-200/50 text-red-800 font-serif shadow-sm">
<span className="text-[10px] leading-tight text-center px-1">
{event.lunarDisplay}
</span>
</div>
) : (
// 公历日期框 - 蓝色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border-2 border-blue-200/50 text-blue-800 font-serif shadow-sm">
<span className="text-xs font-medium">
{event.month}
</span>
<span className="font-bold text-xl leading-tight">{event.day}</span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-sm flex items-center gap-2">
<MemberNameWithStatus
name={event.member.fullName}
isDead={!!event.member.deathDate}
/> {event.type === 'birth' ? '诞辰' : '忌日'}
{event.isLunar && (
<span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{event.member.generation} · {yearsAgo}
{event.type === 'death' && ' · 已故'}
{event.isLunar && event.lunarDisplay && (
<span className="ml-1">· {event.lunarDisplay}</span>
)}
</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 flex-1 overflow-y-auto">
<StatisticsCharts members={Object.values(treeData.members)} />
</TabsContent>
{/* 迁徙记录标签页 */}
<TabsContent value="migration" className="mt-0 flex-1 overflow-y-auto">
<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}${currentTree?.id ? `?treeId=${currentTree.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>
{/* 照片放大预览 Dialog */}
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
<DialogContent className="max-w-4xl p-0 bg-black/95 border-none" aria-describedby={undefined}>
<DialogTitle className="sr-only"></DialogTitle>
{selectedPhoto && (
<img
src={selectedPhoto}
alt="照片预览"
className="w-full h-auto max-h-[90vh] object-contain"
/>
)}
</DialogContent>
</Dialog>
</main>
</div>
)
}
function StatCard({
title,
value,
subtitle,
icon,
href,
}: { title: string; value: string; subtitle: string; icon: React.ReactNode; href?: string }) {
const cardContent = (
<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 cursor-pointer">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{title}</p>
<div className="text-primary/70 bg-primary/10 p-2 rounded-lg group-hover:bg-primary/20 transition-colors">
{icon}
</div>
</div>
<div className="flex flex-col space-y-0.5">
<span className="text-2xl 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>
)
if (href) {
return <Link href={href}>{cardContent}</Link>
}
return cardContent
}