1410 lines
68 KiB
TypeScript
1410 lines
68 KiB
TypeScript
"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 { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle, ChineseCardDescription, ChineseDivider } from "@/components/ui/chinese-card"
|
||
import { Dialog, DialogContent, DialogTitle, DialogHeader, DialogDescription, DialogFooter } 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, ChevronDown, ChevronUp, EyeOff, Play, Video, Edit } 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"
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||
import { Switch } from "@/components/ui/switch"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { Label } from "@/components/ui/label"
|
||
import type { FamilyPhoto } from "@/types/family"
|
||
import { useDialog } from "@/components/ui/alert-dialog-custom"
|
||
import { PhotoCard } from "@/components/dashboard/photo-card"
|
||
|
||
// 动态导入统计图表组件(减少初始加载体积)
|
||
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
|
||
}
|
||
}
|
||
|
||
// 判断是否为视频文件
|
||
const isVideoFile = (url: string) => {
|
||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
|
||
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
|
||
}
|
||
|
||
// 计算事件日期的辅助函数
|
||
const calculateEventDate = (
|
||
dateStr: string,
|
||
isLunar: boolean,
|
||
now: Date
|
||
): { date: Date; lunarDisplay?: string } | null => {
|
||
const date = new Date(dateStr)
|
||
let eventDate: Date
|
||
let lunarDisplay: string | undefined
|
||
|
||
if (isLunar) {
|
||
const lunarInfo = solar2lunar(date)
|
||
if (lunarInfo) {
|
||
const thisYearLunar = lunar2solar(
|
||
now.getFullYear(),
|
||
lunarInfo.lunarMonth,
|
||
lunarInfo.lunarDay,
|
||
lunarInfo.isLeap
|
||
)
|
||
if (thisYearLunar) {
|
||
eventDate = thisYearLunar
|
||
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
|
||
} else {
|
||
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
|
||
}
|
||
} else {
|
||
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
|
||
}
|
||
} else {
|
||
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
|
||
}
|
||
|
||
return { date: eventDate, lunarDisplay }
|
||
}
|
||
|
||
// 创建即将到来的事件
|
||
const createUpcomingEvent = (
|
||
member: any,
|
||
type: 'birth' | 'death',
|
||
dateStr: string,
|
||
isLunar: boolean,
|
||
now: Date,
|
||
threeMonthsLater: Date
|
||
) => {
|
||
const result = calculateEventDate(dateStr, isLunar, now)
|
||
if (!result) return null
|
||
|
||
const { date, lunarDisplay } = result
|
||
|
||
if (date >= now && date <= threeMonthsLater) {
|
||
return {
|
||
member,
|
||
type,
|
||
date,
|
||
originalDate: dateStr,
|
||
isLunar,
|
||
lunarDisplay,
|
||
month: date.getMonth() + 1,
|
||
day: date.getDate()
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
const { treeData, isLoading, currentTree, updateMember } = useFamily()
|
||
const { data: session } = useSession()
|
||
const { showAlert } = useDialog()
|
||
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
|
||
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
|
||
const [statsExpanded, setStatsExpanded] = useState(true)
|
||
const [recentActivityExpanded, setRecentActivityExpanded] = useState(false)
|
||
const [adminToggleLoading, setAdminToggleLoading] = useState<string | null>(null)
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||
const [editForm, setEditForm] = useState({ name: '', description: '' })
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
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
|
||
|
||
// 单次遍历计算所有统计数据
|
||
let livingMembers = 0
|
||
let deceasedMembers = 0
|
||
let maleCount = 0
|
||
let femaleCount = 0
|
||
let maxGeneration = 0
|
||
const birthYears: number[] = []
|
||
let totalAge = 0
|
||
let deceasedWithAgeCount = 0
|
||
|
||
members.forEach(m => {
|
||
// 生死统计
|
||
if (m.deathDate) {
|
||
deceasedMembers++
|
||
} else {
|
||
livingMembers++
|
||
}
|
||
|
||
// 性别统计
|
||
if (m.gender === 'MALE') maleCount++
|
||
else if (m.gender === 'FEMALE') femaleCount++
|
||
|
||
// 代数统计
|
||
if (m.generation && m.generation > maxGeneration) {
|
||
maxGeneration = m.generation
|
||
}
|
||
|
||
// 出生年份和寿命统计
|
||
if (m.birthDate) {
|
||
const birthYear = new Date(m.birthDate).getFullYear()
|
||
birthYears.push(birthYear)
|
||
|
||
if (m.deathDate) {
|
||
const deathYear = new Date(m.deathDate).getFullYear()
|
||
totalAge += deathYear - birthYear
|
||
deceasedWithAgeCount++
|
||
}
|
||
}
|
||
})
|
||
|
||
const earliestYear = birthYears.length > 0
|
||
? Math.min(...birthYears)
|
||
: new Date().getFullYear()
|
||
const yearsSpan = birthYears.length > 0
|
||
? new Date().getFullYear() - earliestYear
|
||
: 0
|
||
const averageLifespan = deceasedWithAgeCount > 0
|
||
? Math.round(totalAge / deceasedWithAgeCount)
|
||
: 0
|
||
|
||
return {
|
||
totalMembers,
|
||
livingMembers,
|
||
deceasedMembers,
|
||
maleCount,
|
||
femaleCount,
|
||
maxGeneration,
|
||
yearsSpan,
|
||
earliestYear,
|
||
averageLifespan
|
||
}
|
||
}, [treeData.members])
|
||
|
||
// 获取最近的成员(按ID排序,取最新的5个)
|
||
const recentMembers = useMemo(() => {
|
||
return Object.values(treeData.members)
|
||
.sort((a, b) => parseInt(b.id) - parseInt(a.id))
|
||
.slice(0, 5)
|
||
}, [treeData.members])
|
||
|
||
// 收集所有成员的照片
|
||
const isOwner = session && currentTree?.ownerId === session.user?.id
|
||
|
||
const allPhotos = useMemo(() => {
|
||
const photos: Array<{
|
||
url: string
|
||
caption?: string
|
||
uploadedAt: string
|
||
memberId: string
|
||
memberName: string
|
||
isDead: boolean
|
||
adminVisibleOverride: boolean
|
||
visibleInOverview: boolean
|
||
}> = []
|
||
|
||
Object.values(treeData.members).forEach(member => {
|
||
if (member.photos && member.photos.length > 0) {
|
||
(member.photos as FamilyPhoto[]).forEach(photo => {
|
||
const visible = photo.visibleInOverview ?? false
|
||
if (!visible) return
|
||
const adminAllowed = photo.adminVisibleOverride ?? true
|
||
if (!adminAllowed && !isOwner) return
|
||
photos.push({
|
||
url: photo.url,
|
||
caption: photo.caption,
|
||
uploadedAt: photo.uploadedAt,
|
||
memberId: member.id,
|
||
memberName: member.fullName,
|
||
isDead: !!member.deathDate,
|
||
adminVisibleOverride: adminAllowed,
|
||
visibleInOverview: visible,
|
||
})
|
||
})
|
||
}
|
||
})
|
||
|
||
return photos.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
|
||
}, [treeData.members, isOwner])
|
||
|
||
const groupedPhotosByMonth = useMemo(() => {
|
||
const sortedPhotos = [...allPhotos].sort((a, b) =>
|
||
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
|
||
)
|
||
const groupedByMonth: Record<string, typeof allPhotos> = {}
|
||
sortedPhotos.forEach(photo => {
|
||
const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月')
|
||
if (!groupedByMonth[monthKey]) {
|
||
groupedByMonth[monthKey] = []
|
||
}
|
||
groupedByMonth[monthKey].push(photo)
|
||
})
|
||
return Object.entries(groupedByMonth)
|
||
}, [allPhotos])
|
||
|
||
const handleAdminPhotoToggle = useCallback(async (memberId: string, photoUrl: string, value: boolean) => {
|
||
if (!isOwner) return
|
||
const member = treeData.members[memberId]
|
||
if (!member || !member.photos || member.photos.length === 0) return
|
||
const key = `${memberId}|${photoUrl}`
|
||
setAdminToggleLoading(key)
|
||
try {
|
||
const updatedPhotos = (member.photos as FamilyPhoto[]).map(photo =>
|
||
photo.url === photoUrl ? { ...photo, adminVisibleOverride: value } : photo
|
||
)
|
||
await updateMember(memberId, { photos: updatedPhotos })
|
||
} catch (error) {
|
||
console.error('更新管理员展示权限失败:', error)
|
||
} finally {
|
||
setAdminToggleLoading(null)
|
||
}
|
||
}, [isOwner, updateMember])
|
||
|
||
// 保存编辑
|
||
const handleSaveEdit = async () => {
|
||
if (!currentTree?.id || !editForm.name.trim()) return
|
||
|
||
setIsSaving(true)
|
||
try {
|
||
const response = await fetch(`/api/trees/${currentTree.id}`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
name: editForm.name.trim(),
|
||
description: editForm.description.trim() || null,
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('更新失败')
|
||
}
|
||
|
||
setEditDialogOpen(false)
|
||
// 刷新页面以显示更新后的数据
|
||
window.location.reload()
|
||
} catch (error) {
|
||
console.error('更新家族信息失败:', error)
|
||
await showAlert('更新失败,请重试', "错误")
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
// 获取本月纪念日(生日和忌日)- 支持农历
|
||
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
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
// 按日期排序
|
||
const sorted = anniversaries.sort((a, b) => a.day - b.day)
|
||
|
||
return sorted
|
||
}, [treeData.members])
|
||
|
||
// 计算未来三月纪念日 - 优化版本
|
||
const upcomingEvents = useMemo(() => {
|
||
const now = new Date()
|
||
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
|
||
const members = Object.values(treeData.members)
|
||
const events: 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 birthEvent = createUpcomingEvent(
|
||
member,
|
||
'birth',
|
||
member.birthDate,
|
||
member.isLunarDate || false,
|
||
now,
|
||
threeMonthsLater
|
||
)
|
||
if (birthEvent) events.push(birthEvent)
|
||
}
|
||
|
||
// 忌日
|
||
if (member.deathDate) {
|
||
const deathEvent = createUpcomingEvent(
|
||
member,
|
||
'death',
|
||
member.deathDate,
|
||
member.isLunarDate || false,
|
||
now,
|
||
threeMonthsLater
|
||
)
|
||
if (deathEvent) events.push(deathEvent)
|
||
}
|
||
})
|
||
|
||
return events.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||
}, [treeData.members])
|
||
|
||
// 计算家族迁徙记录
|
||
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.members])
|
||
|
||
// 如果用户没有家族树,显示欢迎页面
|
||
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-4 md:py-8 px-3 md:px-6 flex flex-col overflow-y-auto overflow-x-hidden">
|
||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-4 mb-4 md:mb-8 flex-shrink-0">
|
||
<div>
|
||
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
|
||
<h1 className="text-2xl md: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 />
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2"
|
||
onClick={() => {
|
||
setEditForm({
|
||
name: currentTree?.name || '',
|
||
description: currentTree?.description || ''
|
||
})
|
||
setEditDialogOpen(true)
|
||
}}
|
||
>
|
||
<Edit className="h-4 w-4" />
|
||
编辑
|
||
</Button>
|
||
</>
|
||
)}
|
||
{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>
|
||
|
||
<Collapsible open={statsExpanded} onOpenChange={setStatsExpanded} className="mb-4 md:mb-8 flex-shrink-0">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<CollapsibleTrigger asChild>
|
||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground -ml-2">
|
||
{statsExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||
<span className="text-sm font-medium">统计概览</span>
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
</div>
|
||
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4">
|
||
{/* 第一个卡片:家族成员 + 在世/已故 */}
|
||
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
|
||
<ChineseCard variant="default" cornerOrnament className="chinese-stat-card transition-all hover:shadow-lg group cursor-pointer h-full">
|
||
<ChineseCardContent className="p-4 md:p-5 pt-5 md:pt-6">
|
||
<div className="flex items-center justify-between mb-2 md:mb-3">
|
||
<p className="text-xs md:text-sm font-semibold text-muted-foreground uppercase tracking-wide">家族成员</p>
|
||
<div className="chinese-seal chinese-seal-sm">
|
||
<Users className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col space-y-1">
|
||
<span className="text-2xl md:text-3xl font-serif font-bold text-foreground tracking-tight">{stats.totalMembers} 人</span>
|
||
<ChineseDivider variant="simple" className="my-1.5 md:my-2" />
|
||
<div className="flex items-center gap-2 md:gap-3 text-xs md: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>
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</Link>
|
||
|
||
{/* 第二个卡片:繁衍代数 + 记录年代 + 平均寿命 */}
|
||
<Link href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : '#'}>
|
||
<ChineseCard variant="default" cornerOrnament className="chinese-stat-card transition-all hover:shadow-lg group cursor-pointer h-full">
|
||
<ChineseCardContent className="p-4 md:p-5 pt-5 md:pt-6">
|
||
<div className="flex items-center justify-between mb-2 md:mb-3">
|
||
<p className="text-xs md:text-sm font-semibold text-muted-foreground uppercase tracking-wide">家族历史</p>
|
||
<div className="chinese-seal chinese-seal-sm">
|
||
<Clock className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col space-y-1">
|
||
<span className="text-2xl md:text-3xl font-serif font-bold text-foreground tracking-tight">{stats.maxGeneration} 代</span>
|
||
<ChineseDivider variant="simple" className="my-1.5 md:my-2" />
|
||
<div className="flex items-center gap-2 md:gap-3 text-xs md:text-sm text-muted-foreground/80 font-medium flex-wrap">
|
||
<span>跨越 {stats.yearsSpan} 年</span>
|
||
<span className="text-muted-foreground">·</span>
|
||
<span>均寿 {stats.averageLifespan > 0 ? `${stats.averageLifespan}岁` : '-'}</span>
|
||
</div>
|
||
</div>
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</Link>
|
||
|
||
{/* 第三个卡片:性别比例 */}
|
||
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
|
||
<ChineseCard variant="default" cornerOrnament className="chinese-stat-card transition-all hover:shadow-lg group cursor-pointer h-full">
|
||
<ChineseCardContent className="p-4 md:p-5 pt-5 md:pt-6">
|
||
<div className="flex items-center justify-between mb-2 md:mb-3">
|
||
<p className="text-xs md:text-sm font-semibold text-muted-foreground uppercase tracking-wide">性别比例</p>
|
||
<div className="chinese-seal chinese-seal-sm">
|
||
<Users className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col space-y-1">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<span className="text-2xl md:text-3xl font-serif font-bold text-primary tracking-tight">
|
||
{stats.maleCount > 0 ? '1' : '0'}
|
||
</span>
|
||
<span className="text-xl md:text-2xl font-bold text-muted-foreground">:</span>
|
||
<span className="text-2xl md:text-3xl font-serif font-bold text-primary tracking-tight">
|
||
{stats.maleCount > 0 ? (stats.femaleCount / stats.maleCount).toFixed(2) : '0'}
|
||
</span>
|
||
</div>
|
||
<ChineseDivider variant="simple" className="my-1.5 md:my-2" />
|
||
<div className="text-center text-xs md:text-sm text-muted-foreground/80 font-medium">
|
||
男性 {stats.maleCount} · 女性 {stats.femaleCount}
|
||
</div>
|
||
</div>
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</Link>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
<Tabs defaultValue="photos" className="w-full flex-1 flex flex-col min-h-0">
|
||
<div className="flex items-center justify-between mb-3 md:mb-4 flex-shrink-0 gap-2">
|
||
<TabsList className="bg-muted/50 h-8 md:h-9">
|
||
<TabsTrigger value="photos" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8">照片</TabsTrigger>
|
||
<TabsTrigger value="recent" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8">动态</TabsTrigger>
|
||
<TabsTrigger value="statistics" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8">图表</TabsTrigger>
|
||
<TabsTrigger value="migration" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8">籍贯</TabsTrigger>
|
||
</TabsList>
|
||
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '/members'}>
|
||
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1 text-[11px] md:text-sm h-8 px-2 md:px-3">
|
||
全部 <ArrowRight className="h-3 w-3" />
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
|
||
{/* 照片展示标签页 */}
|
||
<TabsContent value="photos" className="mt-0 flex-1 overflow-y-auto">
|
||
<ChineseCard variant="scroll" className="shadow-sm">
|
||
<ChineseCardHeader className="pt-6">
|
||
<ChineseCardTitle className="flex items-center gap-2">
|
||
<Images className="h-5 w-5 text-primary" />
|
||
家族影像
|
||
</ChineseCardTitle>
|
||
<ChineseCardDescription>珍藏的家族照片,记录美好时光</ChineseCardDescription>
|
||
</ChineseCardHeader>
|
||
<ChineseCardContent>
|
||
{allPhotos.length > 0 ? (
|
||
<div className="space-y-8">
|
||
{/* 按月份分组显示 */}
|
||
{groupedPhotosByMonth.map(([month, monthPhotos]) => (
|
||
<div key={month}>
|
||
<h4 className="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2 sticky top-0 bg-card/95 backdrop-blur py-2 z-10">
|
||
<span className="w-2 h-2 rounded-full bg-primary"></span>
|
||
{month}
|
||
<span className="text-xs text-muted-foreground/70">({monthPhotos.length})</span>
|
||
</h4>
|
||
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
|
||
{monthPhotos.map((photo, index) => (
|
||
<div
|
||
key={`${photo.memberId}-${index}`}
|
||
className="break-inside-avoid group"
|
||
>
|
||
<PhotoCard
|
||
photo={photo}
|
||
isOwner={isOwner ?? false}
|
||
currentTree={currentTree ?? undefined}
|
||
onSelect={setSelectedPhoto}
|
||
onToggle={handleAdminPhotoToggle}
|
||
isLoading={adminToggleLoading === `${photo.memberId}|${photo.url}`}
|
||
/>
|
||
</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>
|
||
)}
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="recent" className="mt-0 flex-1 overflow-y-auto">
|
||
<div className="space-y-6">
|
||
{/* 第一行:本月祭祖和未来三月纪念日 */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<ChineseCard variant="plaque" className="shadow-sm">
|
||
<ChineseCardHeader>
|
||
<ChineseCardTitle className="flex items-center gap-2 text-primary">
|
||
<Calendar className="h-5 w-5" />
|
||
本月祭祖/纪念
|
||
</ChineseCardTitle>
|
||
<ChineseCardDescription>本月需要纪念的家族成员生日与忌日</ChineseCardDescription>
|
||
</ChineseCardHeader>
|
||
<ChineseCardContent className="max-h-full overflow-y-auto">
|
||
{monthlyAnniversaries.length > 0 ? (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* 诞辰列 */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||
<span className="w-1 h-4 bg-green-500 rounded"></span>
|
||
诞辰
|
||
<span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
|
||
{monthlyAnniversaries.filter(a => a.type === 'birth').length}
|
||
</span>
|
||
</h3>
|
||
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
|
||
{monthlyAnniversaries.filter(a => a.type === 'birth').length > 0 ? (
|
||
monthlyAnniversaries.filter(a => a.type === 'birth').map((anniversary) => {
|
||
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-3 p-2.5 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-12 h-12 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 flex-shrink-0">
|
||
<span className="text-[9px] leading-tight text-center px-1">
|
||
{anniversary.lunarDisplay.replace('月', '')}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center w-12 h-12 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 flex-shrink-0">
|
||
<span className="text-[10px] font-medium">
|
||
{anniversary.month}月
|
||
</span>
|
||
<span className="font-bold text-sm leading-tight">{anniversary.day}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
|
||
<MemberNameWithStatus
|
||
name={anniversary.member.fullName}
|
||
isDead={!!anniversary.member.deathDate}
|
||
/>
|
||
{anniversary.isLunar && (
|
||
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
|
||
农历
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground truncate">
|
||
第 {anniversary.member.generation} 世 · {yearsAgo} 岁
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="text-center py-6">
|
||
<p className="text-xs text-muted-foreground">此月无诞辰</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 忌日列 */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||
<span className="w-1 h-4 bg-gray-500 rounded"></span>
|
||
忌日
|
||
<span className="ml-auto text-xs font-normal text-muted-foreground bg-gray-100 px-2 py-1 rounded border border-gray-200">
|
||
{monthlyAnniversaries.filter(a => a.type === 'death').length}
|
||
</span>
|
||
</h3>
|
||
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
|
||
{monthlyAnniversaries.filter(a => a.type === 'death').length > 0 ? (
|
||
monthlyAnniversaries.filter(a => a.type === 'death').map((anniversary) => {
|
||
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-3 p-2.5 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-12 h-12 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 flex-shrink-0">
|
||
<span className="text-[9px] leading-tight text-center px-1">
|
||
{anniversary.lunarDisplay.replace('月', '')}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center w-12 h-12 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 flex-shrink-0">
|
||
<span className="text-[10px] font-medium">
|
||
{anniversary.month}月
|
||
</span>
|
||
<span className="font-bold text-sm leading-tight">{anniversary.day}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
|
||
<MemberNameWithStatus
|
||
name={anniversary.member.fullName}
|
||
isDead={!!anniversary.member.deathDate}
|
||
/>
|
||
{anniversary.isLunar && (
|
||
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
|
||
农历
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground truncate">
|
||
第 {anniversary.member.generation} 世 · {yearsAgo} 年
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="text-center py-6">
|
||
<p className="text-xs text-muted-foreground">此月无忌日</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12">
|
||
<p className="text-sm text-foreground font-light tracking-wide">此月无忆</p>
|
||
</div>
|
||
)}
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
|
||
<ChineseCard variant="scroll" className="shadow-sm">
|
||
<ChineseCardHeader className="pt-6">
|
||
<ChineseCardTitle className="flex items-center gap-2">
|
||
<Clock className="h-5 w-5 text-primary" />
|
||
未来三月纪念日
|
||
</ChineseCardTitle>
|
||
<ChineseCardDescription>即将到来的生日与忌日提醒</ChineseCardDescription>
|
||
</ChineseCardHeader>
|
||
<ChineseCardContent>
|
||
{(() => {
|
||
const now = new Date()
|
||
const birthEvents = upcomingEvents.filter(e => e.type === 'birth')
|
||
const deathEvents = upcomingEvents.filter(e => e.type === 'death')
|
||
|
||
return upcomingEvents.length > 0 ? (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* 诞辰列 */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||
<span className="w-1 h-4 bg-green-500 rounded"></span>
|
||
诞辰
|
||
<span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
|
||
{birthEvents.length}
|
||
</span>
|
||
</h3>
|
||
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
|
||
{birthEvents.length > 0 ? (
|
||
birthEvents.map((event, index) => {
|
||
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
|
||
const daysUntil = Math.ceil((event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||
return (
|
||
<Link href={`/members/${event.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${event.member.id}-${event.type}-${index}`}>
|
||
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
|
||
{event.isLunar && event.lunarDisplay ? (
|
||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border border-red-200/50 text-red-800 font-serif shadow-sm flex-shrink-0">
|
||
<span className="text-[9px] leading-tight text-center px-1">
|
||
{event.lunarDisplay}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border border-blue-200/50 text-blue-800 font-serif shadow-sm flex-shrink-0">
|
||
<span className="text-[10px] font-medium">
|
||
{event.month}月
|
||
</span>
|
||
<span className="font-bold text-sm leading-tight">{event.day}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
|
||
<MemberNameWithStatus
|
||
name={event.member.fullName}
|
||
isDead={!!event.member.deathDate}
|
||
/>
|
||
{event.isLunar && (
|
||
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
|
||
农历
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground truncate">
|
||
第 {event.member.generation} 世 · {yearsAgo} 岁 · <span className="text-primary font-medium">{daysUntil}天</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="text-center py-6">
|
||
<p className="text-xs text-muted-foreground">无诞辰</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 忌日列 */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||
<span className="w-1 h-4 bg-gray-500 rounded"></span>
|
||
忌日
|
||
<span className="ml-auto text-xs font-normal text-muted-foreground bg-gray-100 px-2 py-1 rounded border border-gray-200">
|
||
{deathEvents.length}
|
||
</span>
|
||
</h3>
|
||
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
|
||
{deathEvents.length > 0 ? (
|
||
deathEvents.map((event, index) => {
|
||
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
|
||
const daysUntil = Math.ceil((event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||
return (
|
||
<Link href={`/members/${event.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${event.member.id}-${event.type}-${index}`}>
|
||
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
|
||
{event.isLunar && event.lunarDisplay ? (
|
||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border border-red-200/50 text-red-800 font-serif shadow-sm flex-shrink-0">
|
||
<span className="text-[9px] leading-tight text-center px-1">
|
||
{event.lunarDisplay}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border border-blue-200/50 text-blue-800 font-serif shadow-sm flex-shrink-0">
|
||
<span className="text-[10px] font-medium">
|
||
{event.month}月
|
||
</span>
|
||
<span className="font-bold text-sm leading-tight">{event.day}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
|
||
<MemberNameWithStatus
|
||
name={event.member.fullName}
|
||
isDead={!!event.member.deathDate}
|
||
/>
|
||
{event.isLunar && (
|
||
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
|
||
农历
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground truncate">
|
||
第 {event.member.generation} 世 · {yearsAgo} 年 · <span className="text-primary font-medium">{daysUntil}天</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="text-center py-6">
|
||
<p className="text-xs text-muted-foreground">无忌日</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-8">
|
||
<p className="text-sm text-foreground font-light tracking-wide">来日无期</p>
|
||
</div>
|
||
)
|
||
})()}
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</div>
|
||
|
||
{/* 第二行:近期修谱动态(折叠状态) */}
|
||
<Collapsible open={recentActivityExpanded} onOpenChange={setRecentActivityExpanded} className="w-full">
|
||
<ChineseCard variant="default" cornerOrnament className="shadow-sm">
|
||
<ChineseCardHeader>
|
||
<div className="flex items-center justify-between w-full">
|
||
<div className="flex items-center gap-2">
|
||
<BarChart3 className="h-5 w-5 text-primary" />
|
||
<ChineseCardTitle>近期修谱动态</ChineseCardTitle>
|
||
</div>
|
||
<CollapsibleTrigger asChild>
|
||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||
{recentActivityExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
</div>
|
||
<ChineseCardDescription>记录家族成员的最新变动与修订</ChineseCardDescription>
|
||
</ChineseCardHeader>
|
||
<CollapsibleContent>
|
||
<ChineseCardContent>
|
||
<ScrollArea className="h-[600px] 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>
|
||
</ChineseCardContent>
|
||
</CollapsibleContent>
|
||
</ChineseCard>
|
||
</Collapsible>
|
||
</div>
|
||
</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">
|
||
<ChineseCard variant="bamboo" className="shadow-sm">
|
||
<ChineseCardHeader>
|
||
<ChineseCardTitle>家族籍贯记录</ChineseCardTitle>
|
||
<ChineseCardDescription>记录家族成员的籍贯分布</ChineseCardDescription>
|
||
</ChineseCardHeader>
|
||
<ChineseCardContent>
|
||
<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>
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* 媒体放大预览 Dialog */}
|
||
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
|
||
<DialogContent
|
||
className="max-w-4xl p-0 bg-black/95 border-none"
|
||
overlayClassName="backdrop-blur-md bg-black/60"
|
||
aria-describedby={undefined}
|
||
>
|
||
<DialogTitle className="sr-only">{selectedPhoto && isVideoFile(selectedPhoto) ? '视频播放' : '照片预览'}</DialogTitle>
|
||
{selectedPhoto && (
|
||
isVideoFile(selectedPhoto) ? (
|
||
<video
|
||
src={selectedPhoto}
|
||
controls
|
||
autoPlay
|
||
className="w-full h-auto max-h-[90vh] object-contain"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={selectedPhoto}
|
||
alt="照片预览"
|
||
className="w-full h-auto max-h-[90vh] object-contain"
|
||
/>
|
||
)
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 编辑家族信息对话框 */}
|
||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||
<DialogContent className="sm:max-w-[500px]">
|
||
<DialogHeader>
|
||
<DialogTitle>编辑家族信息</DialogTitle>
|
||
<DialogDescription>
|
||
修改家族名称和说明
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tree-name">家族名称</Label>
|
||
<Input
|
||
id="tree-name"
|
||
value={editForm.name}
|
||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||
placeholder="请输入家族名称"
|
||
maxLength={50}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tree-description">家族说明</Label>
|
||
<Textarea
|
||
id="tree-description"
|
||
value={editForm.description}
|
||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||
placeholder="请输入家族说明"
|
||
rows={4}
|
||
maxLength={200}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setEditDialogOpen(false)}
|
||
disabled={isSaving}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveEdit}
|
||
disabled={isSaving || !editForm.name.trim()}
|
||
>
|
||
{isSaving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</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
|
||
}
|