This commit is contained in:
freedakgmail
2025-12-22 00:38:20 +08:00
parent 5a604e89f9
commit c2b890994d
9 changed files with 507 additions and 240 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+365 -204
View File
@@ -71,6 +71,7 @@ export default function DashboardPage() {
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([]) const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null) const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
const [statsExpanded, setStatsExpanded] = useState(true) const [statsExpanded, setStatsExpanded] = useState(true)
const [recentActivityExpanded, setRecentActivityExpanded] = useState(false)
const [adminToggleLoading, setAdminToggleLoading] = useState<string | null>(null) const [adminToggleLoading, setAdminToggleLoading] = useState<string | null>(null)
const [editDialogOpen, setEditDialogOpen] = useState(false) const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editForm, setEditForm] = useState({ name: '', description: '' }) const [editForm, setEditForm] = useState({ name: '', description: '' })
@@ -330,7 +331,9 @@ export default function DashboardPage() {
}) })
// 按日期排序 // 按日期排序
return anniversaries.sort((a, b) => a.day - b.day).slice(0, 5) const sorted = anniversaries.sort((a, b) => a.day - b.day)
return sorted
}, [treeData]) }, [treeData])
// 计算家族迁徙记录 // 计算家族迁徙记录
@@ -801,8 +804,9 @@ export default function DashboardPage() {
</TabsContent> </TabsContent>
<TabsContent value="recent" className="mt-0 flex-1 overflow-y-auto"> <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">
<div className="space-y-6"> {/* 第一行:本月祭祖和未来三月纪念日 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChineseCard variant="plaque" className="shadow-sm"> <ChineseCard variant="plaque" className="shadow-sm">
<ChineseCardHeader> <ChineseCardHeader>
<ChineseCardTitle className="flex items-center gap-2 text-primary"> <ChineseCardTitle className="flex items-center gap-2 text-primary">
@@ -811,53 +815,125 @@ export default function DashboardPage() {
</ChineseCardTitle> </ChineseCardTitle>
<ChineseCardDescription></ChineseCardDescription> <ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader> </ChineseCardHeader>
<ChineseCardContent className="grid gap-4"> <ChineseCardContent className="max-h-full overflow-y-auto">
{monthlyAnniversaries.length > 0 ? ( {monthlyAnniversaries.length > 0 ? (
monthlyAnniversaries.map((anniversary, index) => { <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
const yearsAgo = new Date().getFullYear() - new Date(anniversary.date).getFullYear() {/* 诞辰列 */}
return ( <div className="space-y-3">
<Link href={`/members/${anniversary.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${anniversary.member.id}-${anniversary.type}`}> <h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<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"> <span className="w-1 h-4 bg-green-500 rounded"></span>
{anniversary.isLunar && anniversary.lunarDisplay ? (
// 农历日期框 - 红色主题 <span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
<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"> {monthlyAnniversaries.filter(a => a.type === 'birth').length}
<span className="text-[10px] leading-tight text-center px-1"> </span>
{anniversary.lunarDisplay.replace('月', '')} </h3>
</span> <div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
</div> {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()
<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"> return (
<span className="text-xs font-medium"> <Link href={`/members/${anniversary.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${anniversary.member.id}-${anniversary.type}`}>
{anniversary.month} <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">
</span> {anniversary.isLunar && anniversary.lunarDisplay ? (
<span className="font-bold text-xl leading-tight">{anniversary.day}</span> <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">
</div> <span className="text-[9px] leading-tight text-center px-1">
)} {anniversary.lunarDisplay.replace('月', '')}
<div className="flex-1"> </span>
<p className="font-medium flex items-center gap-2"> </div>
<MemberNameWithStatus ) : (
name={anniversary.member.fullName} <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">
isDead={!!anniversary.member.deathDate} <span className="text-[10px] font-medium">
/> {anniversary.type === 'birth' ? '诞辰' : '忌日'} {anniversary.month}
{anniversary.isLunar && ( </span>
<span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200"> <span className="font-bold text-sm leading-tight">{anniversary.day}</span>
</div>
</span> )}
)} <div className="flex-1 min-w-0">
</p> <p className="font-medium text-sm flex items-center gap-1 flex-wrap">
<p className="text-sm text-muted-foreground"> <MemberNameWithStatus
{anniversary.member.generation} · {yearsAgo} name={anniversary.member.fullName}
{anniversary.type === 'death' && ' · 已故'} isDead={!!anniversary.member.deathDate}
{anniversary.isLunar && anniversary.lunarDisplay && ( />
<span className="ml-1">· {anniversary.lunarDisplay}</span> {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">
</p>
</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> )}
</Link> </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"> <div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p> <p className="text-sm text-foreground font-light tracking-wide"></p>
@@ -875,184 +951,267 @@ export default function DashboardPage() {
<ChineseCardDescription></ChineseCardDescription> <ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader> </ChineseCardHeader>
<ChineseCardContent> <ChineseCardContent>
<div className="space-y-3"> {(() => {
{(() => { const now = new Date()
const now = new Date() const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate()) const members = Object.values(treeData.members)
const members = Object.values(treeData.members) const upcomingEvents: Array<{
const upcomingEvents: Array<{ member: any
member: any type: 'birth' | 'death'
type: 'birth' | 'death' date: Date
date: Date originalDate: string
originalDate: string isLunar: boolean
isLunar: boolean lunarDisplay?: string
lunarDisplay?: string month: number
month: number day: number
day: number }> = []
}> = []
members.forEach(member => { members.forEach(member => {
// 生日 // 生日
if (member.birthDate) { if (member.birthDate) {
const birthDate = new Date(member.birthDate) const birthDate = new Date(member.birthDate)
let thisYearBirth: Date let thisYearBirth: Date
let lunarDisplay: string | undefined let lunarDisplay: string | undefined
// 检查是否按农历计算 // 检查是否按农历计算
if (member.isLunarDate) { if (member.isLunarDate) {
// 农历生日:计算今年对应的公历日期 // 农历生日:计算今年对应的公历日期
const lunarInfo = solar2lunar(birthDate) const lunarInfo = solar2lunar(birthDate)
if (lunarInfo) { if (lunarInfo) {
const thisYearLunar = lunar2solar( const thisYearLunar = lunar2solar(
now.getFullYear(), now.getFullYear(),
lunarInfo.lunarMonth, lunarInfo.lunarMonth,
lunarInfo.lunarDay, lunarInfo.lunarDay,
lunarInfo.isLeap lunarInfo.isLeap
) )
if (thisYearLunar) { if (thisYearLunar) {
thisYearBirth = thisYearLunar thisYearBirth = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}` lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
} else { } else {
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate()) thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
} }
} else { } else {
// 公历生日
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate()) thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
} }
} else {
if (thisYearBirth >= now && thisYearBirth <= threeMonthsLater) { // 公历生日
upcomingEvents.push({ thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
member,
type: 'birth',
date: thisYearBirth,
originalDate: member.birthDate,
isLunar: member.isLunarDate || false,
lunarDisplay,
month: thisYearBirth.getMonth() + 1,
day: thisYearBirth.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) { if (member.deathDate) {
const deathDate = new Date(member.deathDate) const deathDate = new Date(member.deathDate)
let thisYearDeath: Date let thisYearDeath: Date
let lunarDisplay: string | undefined let lunarDisplay: string | undefined
// 检查是否按农历计算 // 检查是否按农历计算
if (member.isLunarDate) { if (member.isLunarDate) {
// 农历忌日:计算今年对应的公历日期 // 农历忌日:计算今年对应的公历日期
const lunarInfo = solar2lunar(deathDate) const lunarInfo = solar2lunar(deathDate)
if (lunarInfo) { if (lunarInfo) {
const thisYearLunar = lunar2solar( const thisYearLunar = lunar2solar(
now.getFullYear(), now.getFullYear(),
lunarInfo.lunarMonth, lunarInfo.lunarMonth,
lunarInfo.lunarDay, lunarInfo.lunarDay,
lunarInfo.isLeap lunarInfo.isLeap
) )
if (thisYearLunar) { if (thisYearLunar) {
thisYearDeath = thisYearLunar thisYearDeath = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}` lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
} else { } else {
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate()) thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
} }
} else { } else {
// 公历忌日
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate()) thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
} }
} else {
if (thisYearDeath >= now && thisYearDeath <= threeMonthsLater) { // 公历忌日
upcomingEvents.push({ thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
member,
type: 'death',
date: thisYearDeath,
originalDate: member.deathDate,
isLunar: member.isLunarDate || false,
lunarDisplay,
month: thisYearDeath.getMonth() + 1,
day: thisYearDeath.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()) upcomingEvents.sort((a, b) => a.date.getTime() - b.date.getTime())
return upcomingEvents.length > 0 ? ( const birthEvents = upcomingEvents.filter(e => e.type === 'birth')
upcomingEvents.slice(0, 5).map((event, index) => { const deathEvents = upcomingEvents.filter(e => e.type === 'death')
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
const daysUntil = Math.ceil((event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) return upcomingEvents.length > 0 ? (
return ( <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer"> <div className="space-y-3">
{event.isLunar && event.lunarDisplay ? ( <h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
// 农历日期框 - 红色主题 <span className="w-1 h-4 bg-green-500 rounded"></span>
<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">
<span className="text-[9px] leading-tight text-center px-1"> <span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
{event.lunarDisplay} {birthEvents.length}
</span> </span>
</div> </h3>
) : ( <div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
// 公历日期框 - 蓝色主题 {birthEvents.length > 0 ? (
<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"> birthEvents.map((event, index) => {
<span className="text-xs font-medium"> const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
{event.month} const daysUntil = Math.ceil((event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
</span> return (
<span className="font-bold text-sm leading-tight">{event.day}</span> <Link href={`/members/${event.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${event.member.id}-${event.type}-${index}`}>
</div> <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-1"> <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">
<p className="font-medium text-sm flex items-center gap-2"> <span className="text-[9px] leading-tight text-center px-1">
<MemberNameWithStatus {event.lunarDisplay}
name={event.member.fullName} </span>
isDead={!!event.member.deathDate} </div>
/> {event.type === 'birth' ? '诞辰' : '忌日'} ) : (
{event.isLunar && ( <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] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200"> <span className="text-[10px] font-medium">
{event.month}
</span> </span>
)} <span className="font-bold text-sm leading-tight">{event.day}</span>
</p> </div>
<p className="text-xs text-muted-foreground"> )}
{event.member.generation} · {yearsAgo} <div className="flex-1 min-w-0">
{event.type === 'death' && ' · 已故'} <p className="font-medium text-sm flex items-center gap-1 flex-wrap">
{event.isLunar && event.lunarDisplay && ( <MemberNameWithStatus
<span className="ml-1">· {event.lunarDisplay}</span> name={event.member.fullName}
)} isDead={!!event.member.deathDate}
<span className="ml-1 text-primary">· {daysUntil}</span> />
</p> {event.isLunar && (
</div> <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>
</Link> )}
) </div>
})
) : (
<div className="text-center py-8">
<p className="text-sm text-foreground font-light tracking-wide"></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> </ChineseCardContent>
</ChineseCard> </ChineseCard>
</div> </div>
<ChineseCard variant="default" cornerOrnament className="shadow-sm"> {/* 第二行:近期修谱动态(折叠状态) */}
<ChineseCardHeader> <Collapsible open={recentActivityExpanded} onOpenChange={setRecentActivityExpanded} className="w-full">
<ChineseCardTitle className="flex items-center gap-2"> <ChineseCard variant="default" cornerOrnament className="shadow-sm">
<BarChart3 className="h-5 w-5 text-primary" /> <ChineseCardHeader>
<div className="flex items-center justify-between w-full">
</ChineseCardTitle> <div className="flex items-center gap-2">
<ChineseCardDescription></ChineseCardDescription> <BarChart3 className="h-5 w-5 text-primary" />
</ChineseCardHeader> <ChineseCardTitle></ChineseCardTitle>
<ChineseCardContent> </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"> <ScrollArea className="h-[600px] pr-4">
<div className="space-y-4"> <div className="space-y-4">
{recentActivities.length > 0 ? ( {recentActivities.length > 0 ? (
@@ -1159,8 +1318,10 @@ export default function DashboardPage() {
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
</ChineseCardContent> </ChineseCardContent>
</ChineseCard> </CollapsibleContent>
</ChineseCard>
</Collapsible>
</div> </div>
</TabsContent> </TabsContent>
+55 -3
View File
@@ -47,7 +47,7 @@ import type { D3OrgChartRef } from "@/components/tree/d3-org-chart-flow"
type ViewMode = "traditional" | "d3-org-chart" type ViewMode = "traditional" | "d3-org-chart"
export default function TreePage() { export default function TreePage() {
const { treeData, currentTree, refreshData } = useFamily() const { treeData, currentTree, refreshData, highlightedMemberId, setHighlightedMemberId } = useFamily()
const { calculateRelationship } = useRelationship() const { calculateRelationship } = useRelationship()
const { data: session, status } = useSession() const { data: session, status } = useSession()
const { showAlert } = useDialog() const { showAlert } = useDialog()
@@ -119,9 +119,14 @@ export default function TreePage() {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const d3ChartRef = useRef<D3OrgChartRef>(null) const d3ChartRef = useRef<D3OrgChartRef>(null)
// 传统族谱初始定位到始祖并自适应缩放 // 传统族谱初始定位到始祖并自适应缩放(如果有高亮成员则跳过)
useEffect(() => { useEffect(() => {
if (viewMode !== 'traditional' || hasInitialPositioned || !treeData.rootId) return if (viewMode !== 'traditional' || hasInitialPositioned || !treeData.rootId) return
// 如果有高亮成员,跳过初始定位,让高亮定位逻辑处理
if (highlightedMemberId) {
setHasInitialPositioned(true)
return
}
// 找到始祖成员:优先使用标记为 isFounder 的成员,否则使用 rootId 对应的成员 // 找到始祖成员:优先使用标记为 isFounder 的成员,否则使用 rootId 对应的成员
const founderMember = Object.values(treeData.members).find(m => m.isFounder === true) const founderMember = Object.values(treeData.members).find(m => m.isFounder === true)
@@ -180,7 +185,7 @@ export default function TreePage() {
}, 500) }, 500)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [viewMode, treeData.rootId, treeData.members, hasInitialPositioned]) }, [viewMode, treeData.rootId, treeData.members, hasInitialPositioned, highlightedMemberId])
// 切换视图模式时重置初始定位状态和缩放位置 // 切换视图模式时重置初始定位状态和缩放位置
useEffect(() => { useEffect(() => {
@@ -189,6 +194,53 @@ export default function TreePage() {
setPosition({ x: 0, y: 0 }) setPosition({ x: 0, y: 0 })
}, [viewMode]) }, [viewMode])
// 当有高亮成员时,定位到该成员并居中显示,设置 scale 为 1.0
useEffect(() => {
if (viewMode !== 'traditional' || !highlightedMemberId) return
// 第一步:重置位置和缩放为初始状态
setScale(1)
setPosition({ x: 0, y: 0 })
// 延迟执行,等待 DOM 完全渲染(需要足够长的时间让树完全展开)
const timer = setTimeout(() => {
if (!containerRef.current) {
return
}
// 查找高亮成员的 DOM 元素
const highlightedNode = containerRef.current.querySelector(`[data-member-id="${highlightedMemberId}"]`) as HTMLElement
if (!highlightedNode) {
return
}
const containerRect = containerRef.current.getBoundingClientRect()
const nodeRect = highlightedNode.getBoundingClientRect()
// 计算容器中心
const containerCenterX = containerRect.width / 2
const containerCenterY = containerRect.height / 2
// 此时 position 已经是 {x:0, y:0},所以节点位置就是相对于原点的位置
// 我们需要计算让节点居中的偏移量
const nodeCenterX = nodeRect.left - containerRect.left + nodeRect.width / 2
const nodeCenterY = nodeRect.top - containerRect.top + nodeRect.height / 2
// 计算偏移量
const offsetX = containerCenterX - nodeCenterX
const offsetY = containerCenterY - nodeCenterY
setPosition({ x: offsetX, y: offsetY })
// 30秒后清除高亮状态
setTimeout(() => {
setHighlightedMemberId(null)
}, 30000)
}, 1000) // 增加到1秒,确保DOM完全渲染
return () => clearTimeout(timer)
}, [viewMode, highlightedMemberId, setHighlightedMemberId])
// 传统族谱自适应视图函数 // 传统族谱自适应视图函数
const handleTraditionalFitView = useCallback(() => { const handleTraditionalFitView = useCallback(() => {
if (!containerRef.current || !treeData.rootId) return if (!containerRef.current || !treeData.rootId) return
+4 -2
View File
@@ -695,7 +695,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
value={formData.birthDate || ""} value={formData.birthDate || ""}
isLunar={formData.isLunarDate || false} isLunar={formData.isLunarDate || false}
onChange={(value) => handleChange("birthDate", value)} onChange={(value) => handleChange("birthDate", value)}
showLunarToggle={false} onLunarChange={(isLunar) => handleChange("isLunarDate", isLunar)}
showLunarToggle={true}
/> />
<DateInputWithLunar <DateInputWithLunar
id="deathDate" id="deathDate"
@@ -703,7 +704,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
value={formData.deathDate || ""} value={formData.deathDate || ""}
isLunar={formData.isLunarDate || false} isLunar={formData.isLunarDate || false}
onChange={(value) => handleChange("deathDate", value)} onChange={(value) => handleChange("deathDate", value)}
showLunarToggle={false} onLunarChange={(isLunar) => handleChange("isLunarDate", isLunar)}
showLunarToggle={true}
/> />
</div> </div>
</ChineseCardContent> </ChineseCardContent>
+51 -23
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { BookOpen, Search, Settings, LogOut, User, ChevronDown, Plus, Trash2, Network, LayoutGrid, Calculator, HelpCircle, Calendar, Menu, X, Home, Users as UsersIcon, Clock, TreePine } from "lucide-react" import { BookOpen, Search, Settings, LogOut, User, ChevronDown, Plus, Trash2, Network, LayoutGrid, Calculator, HelpCircle, Calendar, Menu, X, Home, Users as UsersIcon, Clock, TreePine, MapPin } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@@ -34,7 +34,7 @@ interface FamilyTree {
} }
export function SiteHeader() { export function SiteHeader() {
const { searchMembers, searchResults, currentTree: familyCurrentTree } = useFamily() const { searchMembers, searchResults, currentTree: familyCurrentTree, setHighlightedMemberId } = useFamily()
const { data: session, status } = useSession() const { data: session, status } = useSession()
const { showAlert, showConfirm, showPrompt } = useDialog() const { showAlert, showConfirm, showPrompt } = useDialog()
const pathname = usePathname() const pathname = usePathname()
@@ -241,6 +241,21 @@ export function SiteHeader() {
setShowResults(false) setShowResults(false)
} }
// 在族谱图中定位成员
const handleLocateMember = (memberId: string) => {
setHighlightedMemberId(memberId)
// 如果不在族谱页面,跳转到族谱页面
if (pathname !== '/tree') {
const params = new URLSearchParams()
if (currentTree?.id) {
params.set('treeId', currentTree.id)
}
router.push(`/tree?${params.toString()}`)
}
setSearchQuery("")
setShowResults(false)
}
// 导航菜单项配置 // 导航菜单项配置
const navItems = [ const navItems = [
{ href: "/", label: "总览", icon: Home }, { href: "/", label: "总览", icon: Home },
@@ -474,31 +489,44 @@ export function SiteHeader() {
{showResults && searchResults.length > 0 && ( {showResults && searchResults.length > 0 && (
<div className="absolute top-full mt-2 w-full bg-background border border-border rounded-lg shadow-lg max-h-96 overflow-y-auto z-50"> <div className="absolute top-full mt-2 w-full bg-background border border-border rounded-lg shadow-lg max-h-96 overflow-y-auto z-50">
{searchResults.map((member) => ( {searchResults.map((member) => (
<button <div
key={member.id} key={member.id}
onClick={() => handleSelectMember(member.id)} className="w-full px-4 py-3 hover:bg-muted transition-colors border-b border-border last:border-0 flex items-center gap-2"
className="w-full px-4 py-3 text-left hover:bg-muted transition-colors border-b border-border last:border-0"
> >
<div className="flex items-center gap-3"> <button
<div className="flex-1"> onClick={() => handleSelectMember(member.id)}
<div className="flex items-center gap-2"> className="flex-1 text-left"
<MemberNameWithStatus >
name={member.fullName} <div className="flex items-center gap-3">
isDead={!!member.deathDate} <div className="flex-1">
className="font-medium" <div className="flex items-center gap-2">
/> <MemberNameWithStatus
<span className={`text-xs px-1.5 py-0.5 rounded ${member.gender === "MALE" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}> name={member.fullName}
{member.gender === "MALE" ? "男" : "女"} isDead={!!member.deathDate}
</span> className="font-medium"
<span className="text-xs text-muted-foreground">{member.generation}</span> />
<span className={`text-xs px-1.5 py-0.5 rounded ${member.gender === "MALE" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}>
{member.gender === "MALE" ? "男" : "女"}
</span>
<span className="text-xs text-muted-foreground">{member.generation}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{member.birthDate && `生于 ${member.birthDate}`}
{member.birthPlace && ` · ${member.birthPlace}`}
</p>
</div> </div>
<p className="text-xs text-muted-foreground mt-1">
{member.birthDate && `生于 ${member.birthDate}`}
{member.birthPlace && ` · ${member.birthPlace}`}
</p>
</div> </div>
</div> </button>
</button> <Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0 text-muted-foreground hover:text-primary"
onClick={() => handleLocateMember(member.id)}
title="在族谱图中定位"
>
<MapPin className="h-4 w-4" />
</Button>
</div>
))} ))}
</div> </div>
)} )}
+12 -5
View File
@@ -26,6 +26,7 @@ interface FamilyNodeProps {
isRoot?: boolean isRoot?: boolean
onSelect?: (member: FamilyMember) => void onSelect?: (member: FamilyMember) => void
isHighlighted?: boolean isHighlighted?: boolean
highlightedMemberId?: string | null
relationMode?: boolean relationMode?: boolean
isSelected?: boolean isSelected?: boolean
selectedMembers?: string[] selectedMembers?: string[]
@@ -502,6 +503,7 @@ export function FamilyNode({
isRoot, isRoot,
onSelect, onSelect,
isHighlighted, isHighlighted,
highlightedMemberId,
relationMode, relationMode,
isSelected, isSelected,
selectedMembers = [], selectedMembers = [],
@@ -535,6 +537,11 @@ export function FamilyNode({
// 有配偶时,合并到一个卡片中显示(类似D3布局) // 有配偶时,合并到一个卡片中显示(类似D3布局)
const isMale = member.gender === 'MALE' const isMale = member.gender === 'MALE'
const isDead = !!member.deathDate const isDead = !!member.deathDate
// 检查是否有任何配偶被高亮,如果是则整个卡片高亮
const isAnySpouseHighlighted = spouseList.some(sp => sp.id === highlightedMemberId)
const isCardHighlighted = isHighlighted || isAnySpouseHighlighted
// 使用 isRoot prop 而不是 isFounder 字段 // 使用 isRoot prop 而不是 isFounder 字段
const isFounder = isRoot === true const isFounder = isRoot === true
const avatarUrl = member.avatarUrl const avatarUrl = member.avatarUrl
@@ -552,8 +559,8 @@ export function FamilyNode({
"relative rounded-xl overflow-hidden transition-all duration-300", "relative rounded-xl overflow-hidden transition-all duration-300",
"shadow-[0_4px_20px_rgba(0,0,0,0.08)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.15)]", "shadow-[0_4px_20px_rgba(0,0,0,0.08)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.15)]",
"hover:scale-[1.02] hover:-translate-y-1", "hover:scale-[1.02] hover:-translate-y-1",
isHighlighted isCardHighlighted
? "bg-gradient-to-br from-yellow-50 via-amber-50 to-yellow-100 ring-2 ring-yellow-400" ? "bg-gradient-to-br from-yellow-50 via-amber-50 to-yellow-100 ring-2 ring-yellow-400 shadow-[0_8px_30px_rgba(234,179,8,0.4)]"
: isFounder || isRoot : isFounder || isRoot
? "bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50" ? "bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50"
: "bg-gradient-to-br from-slate-50 via-gray-50 to-slate-100" : "bg-gradient-to-br from-slate-50 via-gray-50 to-slate-100"
@@ -562,7 +569,7 @@ export function FamilyNode({
{/* 顶部装饰条 */} {/* 顶部装饰条 */}
<div className={cn( <div className={cn(
"h-1.5", "h-1.5",
isHighlighted ? "bg-gradient-to-r from-yellow-400 via-amber-500 to-yellow-400" isCardHighlighted ? "bg-gradient-to-r from-yellow-400 via-amber-500 to-yellow-400"
: isFounder || isRoot ? "bg-gradient-to-r from-amber-400 via-orange-500 to-amber-400" : isFounder || isRoot ? "bg-gradient-to-r from-amber-400 via-orange-500 to-amber-400"
: "bg-gradient-to-r from-slate-400 via-gray-500 to-slate-400" : "bg-gradient-to-r from-slate-400 via-gray-500 to-slate-400"
)} /> )} />
@@ -669,8 +676,8 @@ export function FamilyNode({
{/* 配偶卡片 */} {/* 配偶卡片 */}
<div <div
className={cn( className={cn(
"flex flex-col items-center cursor-pointer min-w-[80px] relative", "flex flex-col items-center cursor-pointer min-w-[80px] relative p-2 rounded-lg transition-all",
relationMode && "hover:ring-2 hover:ring-primary/50 rounded-lg p-1" relationMode && "hover:ring-2 hover:ring-primary/50"
)} )}
data-member-id={sp.id} data-member-id={sp.id}
onClick={() => onSelect?.(sp)} onClick={() => onSelect?.(sp)}
+1
View File
@@ -86,6 +86,7 @@ export const TreeNode = memo(function TreeNode({
onSelect={handleSelect} onSelect={handleSelect}
isRoot={isRoot} isRoot={isRoot}
isHighlighted={isHighlighted} isHighlighted={isHighlighted}
highlightedMemberId={highlightedMemberId}
relationMode={relationMode} relationMode={relationMode}
isSelected={isSelected} isSelected={isSelected}
selectedMembers={selectedMembers} selectedMembers={selectedMembers}
+17 -1
View File
@@ -290,7 +290,23 @@ export function isInCurrentMonth(dateStr: string, isLunar: boolean = false): { d
originalLunar.isLeap originalLunar.isLeap
) )
if (!thisYearSolar) return null if (!thisYearSolar) {
// 如果转换失败,尝试用明年的日期
const nextYearSolar = lunar2solar(
currentYear + 1,
originalLunar.lunarMonth,
originalLunar.lunarDay,
originalLunar.isLeap
)
if (nextYearSolar) {
const nextYearMonth = nextYearSolar.getMonth() + 1
const nextYearDay = nextYearSolar.getDate()
if (nextYearMonth === currentMonth) {
return { day: nextYearDay, month: nextYearMonth }
}
}
return null
}
const thisYearMonth = thisYearSolar.getMonth() + 1 const thisYearMonth = thisYearSolar.getMonth() + 1
const thisYearDay = thisYearSolar.getDate() const thisYearDay = thisYearSolar.getDate()
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"lastValidatedTimestamp": 1766327831114, "lastValidatedTimestamp": 1766335044864,
"projects": {}, "projects": {},
"pnpmfiles": [], "pnpmfiles": [],
"settings": { "settings": {