This commit is contained in:
freedakgmail
2025-12-10 10:19:29 +08:00
parent 09809c0e49
commit e9b1641509
144 changed files with 1736 additions and 453 deletions
+137 -131
View File
@@ -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>