This commit is contained in:
freedakgmail
2025-11-22 23:49:24 +08:00
parent b83382b604
commit c033e46782
3580 changed files with 1745279 additions and 2428 deletions
+96 -11
View File
@@ -2,21 +2,72 @@
import type { FamilyMember } from "@/types/family"
import { cn } from "@/lib/utils"
import { useAvatarCache } from "@/hooks/use-avatar-cache"
import { CircleUser, CircleUserRound, Heart } from "lucide-react"
interface FamilyNodeProps {
member: FamilyMember
spouse?: FamilyMember
isRoot?: boolean
onSelect?: (member: FamilyMember) => void
isHighlighted?: boolean
}
export function FamilyNode({ member, spouse, isRoot, onSelect }: FamilyNodeProps) {
// 格式化日期显示(只显示年份)
function formatYear(dateString?: string): string {
if (!dateString) return ''
const year = dateString.split('-')[0]
return year
}
// 计算年龄
function calculateAge(birthDate?: string, deathDate?: string): number | null {
if (!birthDate) return null
const birth = new Date(birthDate)
const end = deathDate ? new Date(deathDate) : new Date()
let age = end.getFullYear() - birth.getFullYear()
const monthDiff = end.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
age--
}
return age
}
// 单个人员卡片组件
function PersonCard({ member, isRoot, isSpouse, onSelect, isHighlighted }: { member: FamilyMember; isRoot?: boolean; isSpouse?: boolean; onSelect?: (member: FamilyMember) => void; isHighlighted?: boolean }) {
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarImageId)
const birthYear = formatYear(member.birthDate)
const deathYear = formatYear(member.deathDate)
const age = calculateAge(member.birthDate, member.deathDate)
const lifeSpan = birthYear
? deathYear
? `${birthYear}-${deathYear}`
: `${birthYear}-`
: ''
const ageText = age !== null
? member.deathDate
? `享年${age}`
: `${age}`
: ''
return (
<div
className={cn(
"relative flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer hover:scale-105 shadow-sm w-32",
isRoot ? "border-primary bg-primary/5" : "border-border bg-card",
member.gender === "female" ? "rounded-full w-28 h-28 justify-center" : "rounded-lg", // Traditional round sky/square earth or just variety
"family-node-card relative flex flex-col items-center gap-2 p-3 rounded-lg transition-all cursor-pointer hover:scale-105 shadow-sm w-32",
isHighlighted
? "border-4 border-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 ring-4 ring-yellow-200 dark:ring-yellow-800 animate-pulse"
: isRoot
? "border-2 border-primary bg-primary/5"
: isSpouse
? "border-2 border-dashed border-border/60 bg-muted/30"
: "border-2 border-border bg-card",
)}
onClick={() => onSelect?.(member)}
>
@@ -24,28 +75,62 @@ export function FamilyNode({ member, spouse, isRoot, onSelect }: FamilyNodeProps
{member.generation}
</div>
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground overflow-hidden border border-border/50">
{member.avatarUrl ? (
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center text-muted-foreground overflow-hidden border border-border/50">
{avatarBlobUrl ? (
<img
src={member.avatarUrl || "/placeholder.svg"}
src={avatarBlobUrl}
alt={member.fullName}
className="h-full w-full object-cover"
/>
) : (
<span className="font-serif font-bold text-lg text-foreground/70">{member.surname}</span>
member.gender === 'female' ? (
<CircleUserRound className="h-8 w-8 text-pink-400" />
) : (
<CircleUser className="h-8 w-8 text-blue-400" />
)
)}
</div>
<div className="text-center">
<div className="font-serif font-bold text-foreground leading-tight">{member.givenName}</div>
<div className="text-center w-full">
<div className="font-serif font-bold text-foreground leading-tight">{member.surname} {member.givenName}</div>
{member.courtesyName && (
<div className="text-[10px] text-muted-foreground mt-0.5"> {member.courtesyName}</div>
)}
{lifeSpan && (
<div className="text-[9px] text-muted-foreground/70 mt-1 font-mono">
{lifeSpan}
</div>
)}
{ageText && (
<div className="text-[9px] text-muted-foreground/60 font-mono">
{ageText}
</div>
)}
{member.phone && (
<div className="text-[9px] text-muted-foreground/60 font-mono truncate">
📱 {member.phone}
</div>
)}
</div>
{member.lifeStatus === "deceased" && (
{member.deathDate && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-neutral-400 ring-2 ring-background" />
)}
</div>
)
}
export function FamilyNode({ member, spouse, isRoot, onSelect, isHighlighted }: FamilyNodeProps) {
if (!spouse) {
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} />
}
// 有配偶时,并排显示
return (
<div className="flex items-center gap-2">
<PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} />
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
<PersonCard member={spouse} isSpouse={true} onSelect={onSelect} />
</div>
)
}
+179 -46
View File
@@ -2,71 +2,169 @@
import type { FamilyMember } from "@/types/family"
import { FamilyNode } from "./family-node"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback } from "react"
import { useRouter } from "next/navigation"
interface TreeLayoutProps {
rootId: string
onSelectMember?: (member: FamilyMember) => void
}
interface Connection {
from: { x: number; y: number }
to: { x: number; y: number }
type: 'vertical' | 'horizontal'
}
export function TreeLayout({ rootId, onSelectMember }: TreeLayoutProps) {
const { getMember } = useFamily()
const { getMember, treeData, highlightedMemberId } = useFamily()
const root = getMember(rootId)
const router = useRouter()
const containerRef = useRef<HTMLDivElement>(null)
const [connections, setConnections] = useState<Connection[]>([])
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// 节点间距配置
const HORIZONTAL_GAP = 32 // 2rem = 32px
const VERTICAL_GAP = 48 // 3rem = 48px
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
// 处理节点点击
const handleNodeClick = useCallback((member: FamilyMember) => {
router.push(`/members/${member.id}`)
}, [router])
// 计算所有连线位置
const calculateConnections = useCallback(() => {
const newConnections: Connection[] = []
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect) return
// 检查是否有足够的节点已注册
if (nodeRefs.current.size === 0) return
nodeRefs.current.forEach((nodeElement, nodeId) => {
const member = getMember(nodeId)
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
const parentRect = nodeElement.getBoundingClientRect()
const parentCenterX = parentRect.left + parentRect.width / 2 - containerRect.left
const parentBottomY = parentRect.bottom - containerRect.top
// 父节点到水平线的垂直连线
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
// 获取所有子节点的位置
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement) return null
const childRect = childElement.getBoundingClientRect()
return {
id: childId,
centerX: childRect.left + childRect.width / 2 - containerRect.left,
topY: childRect.top - containerRect.top
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
if (childPositions.length === 0) return
// 如果有多个子节点,绘制水平连线
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
newConnections.push({
from: { x: leftmostX, y: verticalLineY },
to: { x: rightmostX, y: verticalLineY },
type: 'horizontal'
})
}
// 每个子节点的垂直连线
childPositions.forEach(child => {
newConnections.push({
from: { x: child.centerX, y: verticalLineY },
to: { x: child.centerX, y: child.topY },
type: 'vertical'
})
})
})
setConnections(newConnections)
}, [getMember])
useEffect(() => {
// 多次尝试计算,确保 DOM 完全渲染
const timers: NodeJS.Timeout[] = []
timers.push(setTimeout(calculateConnections, 50))
timers.push(setTimeout(calculateConnections, 150))
timers.push(setTimeout(calculateConnections, 300))
timers.push(setTimeout(calculateConnections, 500))
// 监听窗口大小变化
window.addEventListener('resize', calculateConnections)
return () => {
timers.forEach(timer => clearTimeout(timer))
window.removeEventListener('resize', calculateConnections)
}
}, [calculateConnections, treeData])
if (!root) return null
// Recursive component to render the tree
const TreeNode = ({ memberId }: { memberId: string }) => {
// 递归渲染树节点
const TreeNode = ({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) => {
const member = getMember(memberId)
if (!member) return null
const hasChildren = member.childrenIds && member.childrenIds.length > 0
// 获取配偶(取第一个配偶)
const spouse = member.spouseIds && member.spouseIds.length > 0
? getMember(member.spouseIds[0])
: undefined
return (
<div className="flex flex-col items-center">
<div className="relative z-10">
<FamilyNode member={member} onSelect={onSelectMember} />
{/* Connector to children */}
{hasChildren && <div className="absolute top-full left-1/2 -translate-x-1/2 h-8 w-px bg-border" />}
{/* 节点本身 */}
<div
ref={(el) => {
if (el) {
nodeRefs.current.set(memberId, el)
} else {
nodeRefs.current.delete(memberId)
}
}}
className="relative z-10"
>
<FamilyNode
member={member}
spouse={spouse}
onSelect={handleNodeClick}
isRoot={isRoot}
isHighlighted={member.id === highlightedMemberId}
/>
</div>
{/* 子节点 */}
{hasChildren && (
<div className="flex items-start pt-8 relative">
{/* Horizontal connecting line */}
{member.childrenIds.length > 1 && (
<div className="absolute top-0 left-[50%] -translate-x-1/2 h-px bg-border w-[calc(100%-8rem)]" /> // Rough approx for connector width
)}
<div className="flex gap-8 relative">
{/* Top connecting lines for children */}
{member.childrenIds.length > 1 && (
<div className="absolute top-0 left-0 right-0 h-px bg-transparent">
{/* This needs precise calculation or just use CSS pseudo elements on children */}
</div>
)}
{member.childrenIds.map((childId, index) => (
<div key={childId} className="flex flex-col items-center relative">
{/* Vertical line from parent's horizontal line to child */}
<div className="h-8 w-px bg-border absolute -top-8 left-1/2 -translate-x-1/2"></div>
{/* Horizontal connector fix for first/last child */}
{member.childrenIds.length > 1 && (
<>
{index === 0 && <div className="absolute -top-8 right-0 w-1/2 h-px bg-border"></div>}
{index === member.childrenIds.length - 1 && (
<div className="absolute -top-8 left-0 w-1/2 h-px bg-border"></div>
)}
{index > 0 && index < member.childrenIds.length - 1 && (
<div className="absolute -top-8 left-0 w-full h-px bg-border"></div>
)}
</>
)}
<TreeNode memberId={childId} />
</div>
))}
</div>
<div
className="flex items-start relative"
style={{
marginTop: `${VERTICAL_GAP}px`,
gap: `${HORIZONTAL_GAP}px`
}}
>
{member.childrenIds.map((childId) => (
<TreeNode key={childId} memberId={childId} />
))}
</div>
)}
</div>
@@ -74,8 +172,43 @@ export function TreeLayout({ rootId, onSelectMember }: TreeLayoutProps) {
}
return (
<div className="p-12 min-w-fit">
<TreeNode memberId={rootId} />
<div className="relative">
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
{/* 连线层 - 使用 div 绘制 */}
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
{connections.map((conn, index) => {
const isHorizontal = conn.type === 'horizontal'
// 使用 1px 但通过 transform scale 缩小来实现细线
const width = isHorizontal ? Math.abs(conn.to.x - conn.from.x) : 1
const height = isHorizontal ? 1 : Math.abs(conn.to.y - conn.from.y)
const left = Math.min(conn.from.x, conn.to.x)
const top = Math.min(conn.from.y, conn.to.y)
// 跳过无效的连线(宽度或高度为0)
if (width <= 0 || height <= 0) return null
return (
<div
key={index}
className="absolute bg-foreground/25"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
transform: isHorizontal ? 'scaleY(0.3)' : 'scaleX(0.3)',
transformOrigin: 'top left',
}}
/>
)
})}
</div>
{/* 树节点层 */}
<div className="relative" style={{ zIndex: 10 }}>
<TreeNode memberId={rootId} isRoot={true} />
</div>
</div>
</div>
)
}