0.0.0.4
This commit is contained in:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user