This commit is contained in:
freedakgmail
2025-12-22 07:52:55 +08:00
parent c2b890994d
commit e9822f5c92
18 changed files with 3472 additions and 1065 deletions
+193 -270
View File
@@ -29,6 +29,7 @@ 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(
@@ -64,6 +65,71 @@ const isVideoFile = (url: string) => {
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()
@@ -120,39 +186,59 @@ export default function DashboardPage() {
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
// 单次遍历计算所有统计数据
let livingMembers = 0
let deceasedMembers = 0
let maleCount = 0
let femaleCount = 0
let maxGeneration = 0
const birthYears: number[] = []
let totalAge = 0
let deceasedWithAgeCount = 0
// 性别统计
const maleCount = members.filter(m => m.gender === 'MALE').length
const femaleCount = members.filter(m => m.gender === 'FEMALE').length
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 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)
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 {
@@ -166,14 +252,14 @@ export default function DashboardPage() {
earliestYear,
averageLifespan
}
}, [treeData])
}, [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])
}, [treeData.members])
// 收集所有成员的照片
const isOwner = session && currentTree?.ownerId === session.user?.id
@@ -212,7 +298,22 @@ export default function DashboardPage() {
})
return photos.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
}, [treeData, isOwner])
}, [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
@@ -230,7 +331,7 @@ export default function DashboardPage() {
} finally {
setAdminToggleLoading(null)
}
}, [isOwner, treeData.members, updateMember])
}, [isOwner, updateMember])
// 保存编辑
const handleSaveEdit = async () => {
@@ -334,7 +435,54 @@ export default function DashboardPage() {
const sorted = anniversaries.sort((a, b) => a.day - b.day)
return sorted
}, [treeData])
}, [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(() => {
@@ -351,7 +499,7 @@ export default function DashboardPage() {
})
return Object.entries(groups).sort((a, b) => b[1].length - a[1].length)
}, [treeData])
}, [treeData.members])
// 如果用户没有家族树,显示欢迎页面
if (!isLoading && !currentTree) {
@@ -647,21 +795,7 @@ export default function DashboardPage() {
{allPhotos.length > 0 ? (
<div className="space-y-8">
{/* 按月份分组显示 */}
{(() => {
// 按月份分组
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).map(([month, monthPhotos]) => (
{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>
@@ -674,123 +808,19 @@ export default function DashboardPage() {
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">
{photo.adminVisibleOverride === false ? (
<div className="p-4 space-y-2">
<div className="flex items-center justify-between gap-1.5 text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<span></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), 'MM-dd')}
</span>
</div>
{isOwner ? (
<div className="flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
<span></span>
<Switch
checked={photo.adminVisibleOverride ?? true}
onCheckedChange={(checked) => handleAdminPhotoToggle(photo.memberId, photo.url, checked)}
disabled={adminToggleLoading === `${photo.memberId}|${photo.url}`}
/>
</div>
) : (
<p className="text-[11px] text-muted-foreground"></p>
)}
</div>
) : (
<>
{/* 媒体区域 - 点击放大/播放 */}
<div
className="relative cursor-zoom-in"
onClick={() => setSelectedPhoto(photo.url)}
>
{isVideoFile(photo.url) ? (
<div className="relative">
<video
src={photo.url}
className="w-full h-auto object-cover"
muted
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
<Play className="h-6 w-6 text-black ml-1" />
</div>
</div>
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
<Video className="h-3 w-3" />
</div>
</div>
) : (
<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), 'MM-dd')}
</span>
</div>
{isOwner && (
<div className="mt-2 flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
<span></span>
<Switch
checked={photo.adminVisibleOverride ?? true}
onCheckedChange={(checked) => handleAdminPhotoToggle(photo.memberId, photo.url, checked)}
disabled={adminToggleLoading === `${photo.memberId}|${photo.url}`}
/>
</div>
)}
</div>
</>
)}
</div>
<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">
@@ -861,7 +891,7 @@ export default function DashboardPage() {
)}
</p>
<p className="text-xs text-muted-foreground truncate">
{anniversary.member.generation} · {yearsAgo}
{anniversary.member.generation} · {yearsAgo}
</p>
</div>
</div>
@@ -953,113 +983,6 @@ export default function DashboardPage() {
<ChineseCardContent>
{(() => {
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())
const birthEvents = upcomingEvents.filter(e => e.type === 'birth')
const deathEvents = upcomingEvents.filter(e => e.type === 'death')
@@ -1109,7 +1032,7 @@ export default function DashboardPage() {
)}
</p>
<p className="text-xs text-muted-foreground truncate">
{event.member.generation} · {yearsAgo} · <span className="text-primary font-medium">{daysUntil}</span>
{event.member.generation} · {yearsAgo} · <span className="text-primary font-medium">{daysUntil}</span>
</p>
</div>
</div>