0.6.0.2
This commit is contained in:
@@ -295,9 +295,9 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
|
||||
.neighbourMargin(() => 50)
|
||||
.siblingsMargin(() => 50)
|
||||
.linkUpdate(function (d: any, i: any, arr: any) {
|
||||
// 中国风连线颜色 - 使用深褐色
|
||||
// 中国风连线颜色 - 使用琥珀色
|
||||
d3.select(arr[i])
|
||||
.attr('stroke', '#78716c')
|
||||
.attr('stroke', '#b45309')
|
||||
.attr('stroke-width', 2)
|
||||
})
|
||||
.buttonContent(({ node }: any) => {
|
||||
@@ -311,37 +311,37 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
|
||||
const isFounder = node.isFounder === true
|
||||
|
||||
// 庄严正式的中国风配色 - 主成员
|
||||
// 始祖使用尊贵的金色配色
|
||||
// 始祖使用尊贵的金色配色,已故成员使用灰色
|
||||
const borderColor = isFounder
|
||||
? '#d97706' // amber-600 更亮的金色
|
||||
: isDead ? '#78716c' : isMale ? '#92400e' : '#9f1239'
|
||||
: isDead ? '#78716c' : isMale ? '#92400e' : '#be123c' // 已故用灰色
|
||||
|
||||
const bgGradient = isFounder
|
||||
? 'linear-gradient(to bottom, #fef3c7, #fde68a, #fcd34d)' // 更亮的金色渐变
|
||||
: isDead
|
||||
? 'linear-gradient(to bottom, #f5f5f4, #e7e5e4)'
|
||||
? 'linear-gradient(to bottom, #f5f5f4, #e7e5e4)' // 已故用灰色渐变
|
||||
: isMale
|
||||
? 'linear-gradient(to bottom, #fffbeb, #fef3c7, #fffbeb)'
|
||||
: 'linear-gradient(to bottom, #fff1f2, #fce7f3, #fff1f2)'
|
||||
|
||||
const avatarBorder = isFounder
|
||||
? '#d97706' // amber-600
|
||||
: isDead ? '#78716c' : isMale ? '#b45309' : '#be185d'
|
||||
: isDead ? '#78716c' : isMale ? '#b45309' : '#be185d' // 已故用灰色
|
||||
|
||||
const textColor = isDead ? '#57534e' : '#1c1917'
|
||||
const subTextColor = isDead ? '#78716c' : '#57534e'
|
||||
const textColor = isDead ? '#57534e' : '#1c1917' // 已故用灰色文字
|
||||
const subTextColor = isDead ? '#78716c' : '#57534e' // 已故用灰色副文字
|
||||
|
||||
const badgeBg = isFounder
|
||||
? '#d97706' // amber-600
|
||||
: isDead ? '#57534e' : isMale ? '#92400e' : '#9f1239'
|
||||
: isDead ? '#57534e' : isMale ? '#92400e' : '#be123c' // 已故用灰色
|
||||
|
||||
const badgeText = '#fef3c7'
|
||||
|
||||
// 配偶配色
|
||||
const spIsMale = node.spouseGender === '男'
|
||||
const spIsDead = !!node.spouseDeathYear
|
||||
const spAvatarBorder = spIsDead ? '#78716c' : spIsMale ? '#b45309' : '#be185d'
|
||||
const spTextColor = spIsDead ? '#57534e' : '#1c1917'
|
||||
const spAvatarBorder = spIsDead ? '#78716c' : spIsMale ? '#b45309' : '#be185d' // 已故用灰色
|
||||
const spTextColor = spIsDead ? '#57534e' : '#1c1917' // 已故用灰色
|
||||
|
||||
// 获取头像URL
|
||||
const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null
|
||||
@@ -524,6 +524,7 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
|
||||
})
|
||||
.render()
|
||||
.expandAll() // 初始化时展开所有节点
|
||||
.fit() // 自适应全图显示
|
||||
|
||||
// 注册全局配偶点击处理函数
|
||||
window.d3ChartSpouseClick = (event: Event, spouseId: string) => {
|
||||
|
||||
@@ -197,23 +197,24 @@ function PersonCard({
|
||||
|
||||
const cardContent = (
|
||||
<div
|
||||
data-member-id={member.id}
|
||||
className={cn(
|
||||
"family-node-card relative flex flex-col items-center p-4 transition-all cursor-pointer hover:scale-[1.02] w-[140px]",
|
||||
// 庄严的边框样式 - 双层边框效果
|
||||
"border-[3px] rounded-none",
|
||||
"shadow-lg hover:shadow-xl",
|
||||
// 根据状态设置样式
|
||||
// 根据状态设置样式 - 中国风配色
|
||||
isHighlighted
|
||||
? "border-yellow-600 bg-yellow-50/90 dark:bg-yellow-950/50 ring-2 ring-yellow-400 animate-pulse"
|
||||
: isFounder || isRoot
|
||||
? "border-amber-500 bg-gradient-to-b from-amber-100 via-yellow-50 to-amber-100 dark:from-amber-900/40 dark:to-yellow-900/20 shadow-xl ring-1 ring-amber-300"
|
||||
: isSpouse
|
||||
? "border-dashed border-stone-500 bg-stone-50/50 dark:bg-stone-900/30"
|
||||
? "border-dashed border-amber-400 bg-amber-50/50 dark:bg-amber-900/20"
|
||||
: isDead
|
||||
? "border-stone-500 bg-gradient-to-b from-stone-100 to-stone-50 dark:from-stone-900/50 dark:to-stone-800/30"
|
||||
: isMale
|
||||
? "border-amber-800 bg-gradient-to-b from-amber-50 via-orange-50/30 to-amber-50/80 dark:from-amber-950/40 dark:to-orange-950/20"
|
||||
: "border-rose-800 bg-gradient-to-b from-rose-50 via-pink-50/30 to-rose-50/80 dark:from-rose-950/40 dark:to-pink-950/20",
|
||||
: "border-rose-700 bg-gradient-to-b from-rose-50 via-pink-50/30 to-rose-50/80 dark:from-rose-950/40 dark:to-pink-950/20",
|
||||
relationMode && "hover:shadow-xl"
|
||||
)}
|
||||
onClick={() => onSelect?.(member)}
|
||||
@@ -223,28 +224,28 @@ function PersonCard({
|
||||
"absolute inset-[3px] border pointer-events-none",
|
||||
isHighlighted ? "border-yellow-500/50"
|
||||
: isFounder || isRoot ? "border-amber-400/50"
|
||||
: isSpouse ? "border-stone-400/30"
|
||||
: isSpouse ? "border-amber-300/40"
|
||||
: isDead ? "border-stone-400/40"
|
||||
: isMale ? "border-amber-600/40"
|
||||
: "border-rose-600/40"
|
||||
: "border-rose-500/40"
|
||||
)} />
|
||||
|
||||
{/* 四角装饰 - 更精致的角花 */}
|
||||
<div className={cn(
|
||||
"absolute top-0 left-0 w-4 h-4 border-t-[3px] border-l-[3px]",
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-stone-500" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-800"
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-amber-400" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-700"
|
||||
)} />
|
||||
<div className={cn(
|
||||
"absolute top-0 right-0 w-4 h-4 border-t-[3px] border-r-[3px]",
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-stone-500" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-800"
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-amber-400" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-700"
|
||||
)} />
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 w-4 h-4 border-b-[3px] border-l-[3px]",
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-stone-500" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-800"
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-amber-400" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-700"
|
||||
)} />
|
||||
<div className={cn(
|
||||
"absolute bottom-0 right-0 w-4 h-4 border-b-[3px] border-r-[3px]",
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-stone-500" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-800"
|
||||
isHighlighted ? "border-yellow-600" : isFounder || isRoot ? "border-amber-500" : isSpouse ? "border-amber-400" : isDead ? "border-stone-500" : isMale ? "border-amber-800" : "border-rose-700"
|
||||
)} />
|
||||
|
||||
{/* 选中打钩标记 */}
|
||||
@@ -264,7 +265,7 @@ function PersonCard({
|
||||
? "bg-stone-600 text-stone-100 border-stone-700"
|
||||
: isMale
|
||||
? "bg-amber-800 text-amber-100 border-amber-900"
|
||||
: "bg-rose-800 text-rose-100 border-rose-900"
|
||||
: "bg-rose-700 text-rose-100 border-rose-800"
|
||||
)}>
|
||||
{member.generation}世
|
||||
</div>
|
||||
@@ -279,7 +280,7 @@ function PersonCard({
|
||||
? "border-stone-400"
|
||||
: isMale
|
||||
? "border-amber-700"
|
||||
: "border-rose-700"
|
||||
: "border-rose-600"
|
||||
)}>
|
||||
{avatarBlobUrl ? (
|
||||
<img
|
||||
@@ -289,7 +290,7 @@ function PersonCard({
|
||||
/>
|
||||
) : (
|
||||
member.gender === 'FEMALE' ? (
|
||||
<CircleUserRound className={cn("h-9 w-9", isDead ? "text-stone-400" : "text-rose-700")} />
|
||||
<CircleUserRound className={cn("h-9 w-9", isDead ? "text-stone-400" : "text-rose-600")} />
|
||||
) : (
|
||||
<CircleUser className={cn("h-9 w-9", isDead ? "text-stone-400" : "text-amber-700")} />
|
||||
)
|
||||
@@ -346,7 +347,7 @@ function PersonCard({
|
||||
"bg-white border-2 shadow-md transition-all hover:scale-110",
|
||||
isCollapsed
|
||||
? "border-amber-500 text-amber-600"
|
||||
: "border-stone-400 text-stone-500"
|
||||
: "border-amber-400 text-amber-500"
|
||||
)}
|
||||
title={isCollapsed ? "展开子孙" : "折叠子孙"}
|
||||
>
|
||||
|
||||
+137
-131
@@ -2,6 +2,7 @@
|
||||
import type React from "react"
|
||||
import type { FamilyMember } from "@/types/family"
|
||||
import { FamilyNode } from "./family-node"
|
||||
import { TreeNode } from "./tree-node"
|
||||
import { AddRelationDialog, type RelationType } from "./add-relation-dialog"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
|
||||
@@ -14,6 +15,9 @@ interface TreeLayoutProps {
|
||||
relationMode?: boolean
|
||||
selectedMembers?: string[]
|
||||
onMemberClick?: (memberId: string) => void
|
||||
expandAll?: boolean
|
||||
onExpandAllChange?: (expanded: boolean) => void
|
||||
scale?: number
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
@@ -27,7 +31,10 @@ export function TreeLayout({
|
||||
onSelectMember,
|
||||
relationMode = false,
|
||||
selectedMembers = [],
|
||||
onMemberClick
|
||||
onMemberClick,
|
||||
expandAll = false,
|
||||
onExpandAllChange,
|
||||
scale = 1
|
||||
}: TreeLayoutProps) {
|
||||
const { getMember, treeData, highlightedMemberId, currentTree } = useFamily()
|
||||
|
||||
@@ -40,69 +47,49 @@ export function TreeLayout({
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
const [relationDialog, setRelationDialog] = useState<{ member: FamilyMember; type: "child" | "spouse" } | null>(null)
|
||||
|
||||
|
||||
// 重新计算连线的函数(使用 ref 避免依赖问题)
|
||||
const recalculateConnections = useCallback(() => {
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
if (!containerRect || nodeRefs.current.size === 0) return
|
||||
|
||||
const newConnections: Connection[] = []
|
||||
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 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
|
||||
|
||||
const verticalLineY = parentBottomY + 24
|
||||
newConnections.push({
|
||||
from: { x: parentCenterX, y: parentBottomY },
|
||||
to: { x: parentCenterX, y: verticalLineY },
|
||||
type: 'vertical'
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
|
||||
// 节点间距配置
|
||||
const HORIZONTAL_GAP = 32 // 2rem = 32px
|
||||
const VERTICAL_GAP = 48 // 3rem = 48px
|
||||
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
|
||||
|
||||
// 折叠状态管理:存储被折叠的节点ID
|
||||
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(() => new Set())
|
||||
|
||||
// 切换节点折叠状态
|
||||
const toggleCollapse = useCallback((memberId: string) => {
|
||||
setCollapsedNodes(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(memberId)) {
|
||||
newSet.delete(memberId)
|
||||
} else {
|
||||
newSet.add(memberId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 响应 expandAll 变化,展开所有节点
|
||||
useEffect(() => {
|
||||
if (expandAll) {
|
||||
setCollapsedNodes(new Set())
|
||||
// 通知父组件已展开
|
||||
onExpandAllChange?.(false)
|
||||
}
|
||||
}, [expandAll, onExpandAllChange])
|
||||
|
||||
// 处理节点 ref 注册
|
||||
const handleNodeRef = useCallback((memberId: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
nodeRefs.current.set(memberId, el)
|
||||
} else {
|
||||
nodeRefs.current.delete(memberId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 处理添加关系
|
||||
const handleAddRelation = useCallback((member: FamilyMember, type: "child" | "spouse") => {
|
||||
setRelationDialog({ member, type })
|
||||
}, [])
|
||||
|
||||
// 处理节点点击
|
||||
const handleNodeClick = useCallback((member: FamilyMember) => {
|
||||
@@ -114,6 +101,24 @@ export function TreeLayout({
|
||||
}
|
||||
}, [relationMode, onMemberClick, router])
|
||||
|
||||
// 检查节点是否被任何祖先折叠(使用闭包避免递归依赖问题)
|
||||
const isNodeHiddenByCollapse = useCallback((nodeId: string): boolean => {
|
||||
const checkHidden = (id: string): boolean => {
|
||||
const member = getMember(id)
|
||||
if (!member) return true
|
||||
|
||||
// 检查父节点是否被折叠
|
||||
const parentId = member.fatherId || member.motherId
|
||||
if (parentId) {
|
||||
if (collapsedNodes.has(parentId)) return true
|
||||
// 递归检查祖先
|
||||
return checkHidden(parentId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return checkHidden(nodeId)
|
||||
}, [getMember, collapsedNodes])
|
||||
|
||||
// 计算所有连线位置
|
||||
const calculateConnections = useCallback(() => {
|
||||
const newConnections: Connection[] = []
|
||||
@@ -126,21 +131,38 @@ export function TreeLayout({
|
||||
nodeRefs.current.forEach((nodeElement, nodeId) => {
|
||||
const member = getMember(nodeId)
|
||||
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
|
||||
|
||||
|
||||
// 如果该节点被折叠,不绘制到子节点的连线
|
||||
if (collapsedNodes.has(nodeId)) return
|
||||
|
||||
// 如果该节点被祖先折叠隐藏,跳过
|
||||
if (isNodeHiddenByCollapse(nodeId)) return
|
||||
|
||||
// 检查元素是否仍在 DOM 中且可见
|
||||
if (!document.body.contains(nodeElement)) return
|
||||
const parentRect = nodeElement.getBoundingClientRect()
|
||||
const parentCenterX = parentRect.left + parentRect.width / 2 - containerRect.left
|
||||
const parentBottomY = parentRect.bottom - containerRect.top
|
||||
// 如果元素不可见(宽高为0),跳过
|
||||
if (parentRect.width === 0 || parentRect.height === 0) return
|
||||
|
||||
// 考虑缩放因素:getBoundingClientRect 返回的是缩放后的尺寸
|
||||
// 需要除以 scale 来获取实际的逻辑位置
|
||||
const parentCenterX = (parentRect.left + parentRect.width / 2 - containerRect.left) / scale
|
||||
const parentBottomY = (parentRect.bottom - containerRect.top) / scale
|
||||
|
||||
// 获取所有子节点的位置
|
||||
const childPositions = member.childrenIds
|
||||
.map(childId => {
|
||||
const childElement = nodeRefs.current.get(childId)
|
||||
if (!childElement) return null
|
||||
// 检查子元素是否仍在 DOM 中
|
||||
if (!document.body.contains(childElement)) return null
|
||||
const childRect = childElement.getBoundingClientRect()
|
||||
// 如果子元素不可见,跳过
|
||||
if (childRect.width === 0 || childRect.height === 0) return null
|
||||
return {
|
||||
id: childId,
|
||||
centerX: childRect.left + childRect.width / 2 - containerRect.left,
|
||||
topY: childRect.top - containerRect.top
|
||||
centerX: (childRect.left + childRect.width / 2 - containerRect.left) / scale,
|
||||
topY: (childRect.top - containerRect.top) / scale
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
|
||||
@@ -178,7 +200,22 @@ export function TreeLayout({
|
||||
})
|
||||
|
||||
setConnections(newConnections)
|
||||
}, [getMember])
|
||||
}, [getMember, collapsedNodes, isNodeHiddenByCollapse, scale])
|
||||
|
||||
// 折叠状态变化时重新计算连线
|
||||
useEffect(() => {
|
||||
// 多次延迟计算,等待 DOM 完全更新
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
timers.push(setTimeout(calculateConnections, 50))
|
||||
timers.push(setTimeout(calculateConnections, 150))
|
||||
timers.push(setTimeout(calculateConnections, 300))
|
||||
return () => timers.forEach(timer => clearTimeout(timer))
|
||||
}, [collapsedNodes, calculateConnections])
|
||||
|
||||
// 缩放变化时重新计算连线
|
||||
useEffect(() => {
|
||||
calculateConnections()
|
||||
}, [scale, calculateConnections])
|
||||
|
||||
useEffect(() => {
|
||||
// 多次尝试计算,确保 DOM 完全渲染
|
||||
@@ -320,69 +357,16 @@ export function TreeLayout({
|
||||
window.location.href = `/members/new?${params.toString()}`
|
||||
}
|
||||
|
||||
// 递归渲染树节点
|
||||
function renderTreeNode(memberId: string, isRoot: boolean = false): React.ReactNode {
|
||||
const member = getMember(memberId)
|
||||
if (!member) return null
|
||||
|
||||
const hasChildren = member.childrenIds && member.childrenIds.length > 0
|
||||
|
||||
// 计算实际要显示的子节点(过滤掉不存在的成员)
|
||||
const visibleChildren = hasChildren
|
||||
? (sortedChildrenMap.get(memberId) || []).filter(childId => getMember(childId) !== undefined)
|
||||
: []
|
||||
const hasVisibleChildren = visibleChildren.length > 0
|
||||
|
||||
// 获取所有配偶
|
||||
const spouses = member.spouseIds && member.spouseIds.length > 0
|
||||
? member.spouseIds.map(id => getMember(id)).filter(Boolean) as FamilyMember[]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div key={memberId} className="flex flex-col items-center">
|
||||
{/* 节点本身 */}
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
nodeRefs.current.set(memberId, el)
|
||||
} else {
|
||||
nodeRefs.current.delete(memberId)
|
||||
}
|
||||
}}
|
||||
className="relative z-10"
|
||||
>
|
||||
<FamilyNode
|
||||
member={member}
|
||||
spouses={spouses}
|
||||
onSelect={handleNodeClick}
|
||||
isRoot={isRoot}
|
||||
isHighlighted={member.id === highlightedMemberId}
|
||||
relationMode={relationMode}
|
||||
isSelected={selectedMembers.includes(member.id)}
|
||||
selectedMembers={selectedMembers}
|
||||
canEdit={canEdit}
|
||||
onAddRelation={(type) => setRelationDialog({ member, type })}
|
||||
hasChildren={false}
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 子节点 */}
|
||||
{hasVisibleChildren && (
|
||||
<div
|
||||
className="flex items-start relative"
|
||||
style={{
|
||||
marginTop: `${VERTICAL_GAP}px`,
|
||||
gap: `${HORIZONTAL_GAP}px`
|
||||
}}
|
||||
>
|
||||
{visibleChildren.map((childId) => renderTreeNode(childId, false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 准备根节点数据
|
||||
const rootSpouses = root.spouseIds && root.spouseIds.length > 0
|
||||
? root.spouseIds.map(id => getMember(id)).filter((s): s is FamilyMember => s !== undefined)
|
||||
: []
|
||||
const rootHasChildren = root.childrenIds && root.childrenIds.length > 0
|
||||
const rootChildren = rootHasChildren
|
||||
? (sortedChildrenMap.get(root.id) || [])
|
||||
.map(id => getMember(id))
|
||||
.filter((c): c is FamilyMember => c !== undefined)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -403,7 +387,7 @@ export function TreeLayout({
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute bg-black"
|
||||
className="absolute bg-amber-700/70 dark:bg-amber-500/60"
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
@@ -419,7 +403,29 @@ export function TreeLayout({
|
||||
|
||||
{/* 树节点层 */}
|
||||
<div className="relative" style={{ zIndex: 10 }} key="tree-root">
|
||||
{renderTreeNode(rootId, true)}
|
||||
<TreeNode
|
||||
member={root}
|
||||
spouses={rootSpouses}
|
||||
children={rootChildren}
|
||||
isRoot={true}
|
||||
isHighlighted={root.id === highlightedMemberId}
|
||||
highlightedMemberId={highlightedMemberId}
|
||||
relationMode={relationMode}
|
||||
isSelected={selectedMembers.includes(root.id)}
|
||||
selectedMembers={selectedMembers}
|
||||
canEdit={canEdit}
|
||||
hasChildren={rootHasChildren || false}
|
||||
isCollapsed={collapsedNodes.has(root.id)}
|
||||
collapsedNodes={collapsedNodes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onAddRelation={handleAddRelation}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
onNodeRef={handleNodeRef}
|
||||
getMember={getMember}
|
||||
sortedChildrenMap={sortedChildrenMap}
|
||||
horizontalGap={HORIZONTAL_GAP}
|
||||
verticalGap={VERTICAL_GAP}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
import React, { memo, useCallback } from "react"
|
||||
import type { FamilyMember } from "@/types/family"
|
||||
import { FamilyNode } from "./family-node"
|
||||
|
||||
interface TreeNodeProps {
|
||||
member: FamilyMember
|
||||
spouses: FamilyMember[]
|
||||
children: FamilyMember[]
|
||||
isRoot: boolean
|
||||
isHighlighted: boolean
|
||||
highlightedMemberId?: string | null
|
||||
relationMode: boolean
|
||||
isSelected: boolean
|
||||
selectedMembers: string[]
|
||||
canEdit: boolean
|
||||
hasChildren: boolean
|
||||
isCollapsed: boolean
|
||||
collapsedNodes: Set<string>
|
||||
onNodeClick: (member: FamilyMember) => void
|
||||
onAddRelation: (member: FamilyMember, type: "child" | "spouse") => void
|
||||
onToggleCollapse: (memberId: string) => void
|
||||
onNodeRef: (memberId: string, el: HTMLDivElement | null) => void
|
||||
getMember: (id: string) => FamilyMember | undefined
|
||||
sortedChildrenMap: Map<string, string[]>
|
||||
horizontalGap: number
|
||||
verticalGap: number
|
||||
}
|
||||
|
||||
// 独立的树节点组件,使用 memo 优化渲染
|
||||
export const TreeNode = memo(function TreeNode({
|
||||
member,
|
||||
spouses,
|
||||
children,
|
||||
isRoot,
|
||||
isHighlighted,
|
||||
highlightedMemberId,
|
||||
relationMode,
|
||||
isSelected,
|
||||
selectedMembers,
|
||||
canEdit,
|
||||
hasChildren,
|
||||
isCollapsed,
|
||||
collapsedNodes,
|
||||
onNodeClick,
|
||||
onAddRelation,
|
||||
onToggleCollapse,
|
||||
onNodeRef,
|
||||
getMember,
|
||||
sortedChildrenMap,
|
||||
horizontalGap,
|
||||
verticalGap,
|
||||
}: TreeNodeProps) {
|
||||
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
onToggleCollapse(member.id)
|
||||
}, [member.id, onToggleCollapse])
|
||||
|
||||
const handleAddRelation = useCallback((type: "child" | "spouse") => {
|
||||
onAddRelation(member, type)
|
||||
}, [member, onAddRelation])
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onNodeClick(member)
|
||||
}, [member, onNodeClick])
|
||||
|
||||
const handleRef = useCallback((el: HTMLDivElement | null) => {
|
||||
onNodeRef(member.id, el)
|
||||
}, [member.id, onNodeRef])
|
||||
|
||||
// 计算可见的子节点
|
||||
const visibleChildren = hasChildren && !isCollapsed
|
||||
? (sortedChildrenMap.get(member.id) || [])
|
||||
.map(childId => getMember(childId))
|
||||
.filter((child): child is FamilyMember => child !== undefined)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* 节点本身 */}
|
||||
<div ref={handleRef} className="relative z-10">
|
||||
<FamilyNode
|
||||
member={member}
|
||||
spouses={spouses}
|
||||
onSelect={handleSelect}
|
||||
isRoot={isRoot}
|
||||
isHighlighted={isHighlighted}
|
||||
relationMode={relationMode}
|
||||
isSelected={isSelected}
|
||||
selectedMembers={selectedMembers}
|
||||
canEdit={canEdit}
|
||||
onAddRelation={handleAddRelation}
|
||||
hasChildren={hasChildren}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 子节点 - 只在未折叠时显示 */}
|
||||
{visibleChildren.length > 0 && (
|
||||
<div
|
||||
className="flex items-start relative"
|
||||
style={{
|
||||
marginTop: `${verticalGap}px`,
|
||||
gap: `${horizontalGap}px`
|
||||
}}
|
||||
>
|
||||
{visibleChildren.map((child) => {
|
||||
const childSpouses = child.spouseIds && child.spouseIds.length > 0
|
||||
? child.spouseIds.map(id => getMember(id)).filter((s): s is FamilyMember => s !== undefined)
|
||||
: []
|
||||
const childHasChildren = child.childrenIds && child.childrenIds.length > 0
|
||||
const childChildren = childHasChildren
|
||||
? (sortedChildrenMap.get(child.id) || [])
|
||||
.map(id => getMember(id))
|
||||
.filter((c): c is FamilyMember => c !== undefined)
|
||||
: []
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
member={child}
|
||||
spouses={childSpouses}
|
||||
children={childChildren}
|
||||
isRoot={false}
|
||||
isHighlighted={child.id === highlightedMemberId}
|
||||
highlightedMemberId={highlightedMemberId}
|
||||
relationMode={relationMode}
|
||||
isSelected={selectedMembers.includes(child.id)}
|
||||
selectedMembers={selectedMembers}
|
||||
canEdit={canEdit}
|
||||
hasChildren={childHasChildren || false}
|
||||
isCollapsed={collapsedNodes.has(child.id)}
|
||||
collapsedNodes={collapsedNodes}
|
||||
onNodeClick={onNodeClick}
|
||||
onAddRelation={onAddRelation}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onNodeRef={onNodeRef}
|
||||
getMember={getMember}
|
||||
sortedChildrenMap={sortedChildrenMap}
|
||||
horizontalGap={horizontalGap}
|
||||
verticalGap={verticalGap}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user