Files
chinese-family-tree-2/components/tree/tree-layout.tsx
T
selfrelease b4536c52ef fix: 连线位置偏移修复 - 改用 offsetLeft/offsetTop 替代 getBoundingClientRect
根因: getBoundingClientRect 返回屏幕坐标(受 scale 影响),
初始定位过程中 scale 从 1→0.45 变化时,连线在中间状态计算,
导致坐标与最终布局不匹配。

修复: 使用 offsetLeft/offsetTop 遍历 offsetParent 链获取本地坐标,
完全不受 CSS transform/scale 影响,消除 scale 依赖。
2026-07-16 16:03:40 +08:00

427 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
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, useLayoutEffect } from "react"
import { useRouter } from "next/navigation"
import { permissions } from "@/lib/permissions"
interface TreeLayoutProps {
rootId: string
onSelectMember?: (member: FamilyMember) => void
relationMode?: boolean
selectedMembers?: string[]
onMemberClick?: (memberId: string) => void
expandAll?: boolean
onExpandAllChange?: (expanded: boolean) => void
scale?: number
}
interface Connection {
from: { x: number; y: number }
to: { x: number; y: number }
type: 'vertical' | 'horizontal'
}
/** 常见复姓列表 */
const COMPOUND_SURNAMES = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
/** 将全名分解为姓和名(模块级函数,避免每次渲染重建) */
function parseName(fullName: string): { surname: string; givenName: string } {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
for (const compound of COMPOUND_SURNAMES) {
if (name.startsWith(compound) && name.length > compound.length) {
return { surname: compound, givenName: name.slice(compound.length) }
}
}
if (name.length >= 2) {
return { surname: name.charAt(0), givenName: name.slice(1) }
}
return { surname: name, givenName: '' }
}
export function TreeLayout({
rootId,
onSelectMember,
relationMode = false,
selectedMembers = [],
onMemberClick,
expandAll = false,
onExpandAllChange,
scale = 1
}: TreeLayoutProps) {
const { getMember, treeData, highlightedMemberId, currentTree } = useFamily()
// 检查用户是否有创建权限
const canEdit = permissions.canCreate(currentTree?.currentUserRole)
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 [relationDialog, setRelationDialog] = useState<{ member: FamilyMember; type: "child" | "spouse" } | null>(null)
// 节点间距配置
const HORIZONTAL_GAP = 16 // 1rem = 16px
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 注册(批量模式,不再触发 N 次 setState
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) => {
if (relationMode && onMemberClick) {
onMemberClick(member.id)
} else {
const params = new URLSearchParams(window.location.search)
router.push(`/members/${member.id}?${params.toString()}`)
}
}, [relationMode, onMemberClick, router])
// 检查节点是否被任何祖先折叠(使用闭包避免递归依赖问题)
// 注意:这个函数用于判断节点是否应该被隐藏,不用于连线计算
const isNodeHiddenByCollapse = useCallback((nodeId: string): boolean => {
// 如果没有折叠的节点,直接返回 false
if (collapsedNodes.size === 0) return false
const checkHidden = (id: string): boolean => {
const member = getMember(id)
if (!member) return false // 找不到成员时返回 false,不隐藏
// 检查父节点是否被折叠
const parentId = member.fatherId || member.motherId
if (parentId) {
if (collapsedNodes.has(parentId)) return true
// 递归检查祖先
return checkHidden(parentId)
}
return false
}
return checkHidden(nodeId)
}, [getMember, collapsedNodes])
// 计算所有连线位置(使用 offsetLeft/offsetTop 获取本地坐标,不受 scale 影响)
const rafIdRef = useRef<number | null>(null)
const calculateConnections = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null
const container = containerRef.current
if (!container || nodeRefs.current.size === 0) return
// 辅助函数:获取元素相对于容器的本地坐标(不受 scale/transform 影响)
const getLocalRect = (el: HTMLElement) => {
let top = 0, left = 0
let node = el
while (node && node !== container) {
top += node.offsetTop
left += node.offsetLeft
node = node.offsetParent as HTMLElement
}
return { top, left, width: el.offsetWidth, height: el.offsetHeight }
}
const newConnections: Connection[] = []
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
if (!document.body.contains(nodeElement)) return
const parentRect = getLocalRect(nodeElement)
if (parentRect.width === 0 || parentRect.height === 0) return
const parentCenterX = parentRect.left + parentRect.width / 2
const parentBottomY = parentRect.top + parentRect.height
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement || !document.body.contains(childElement)) return null
const childRect = getLocalRect(childElement)
if (childRect.width === 0 || childRect.height === 0) return null
return {
id: childId,
centerX: childRect.left + childRect.width / 2,
topY: childRect.top
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
if (childPositions.length === 0) return
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
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, collapsedNodes, isNodeHiddenByCollapse])
// 使用 useLayoutEffect 在 DOM 更新后立即计算连线(替代 7 个 setTimeout
// offsetLeft/offsetTop 不受 scale 影响,无需依赖 scale
useLayoutEffect(() => {
calculateConnections()
// 双 rAF 确保浏览器完成布局后再计算
const raf2 = requestAnimationFrame(() => calculateConnections())
return () => cancelAnimationFrame(raf2)
}, [calculateConnections, treeData, collapsedNodes])
// 使用 ResizeObserver 监听容器尺寸变化(替代 window.resize
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => calculateConnections())
observer.observe(container)
return () => observer.disconnect()
}, [calculateConnections])
// 清理 rAF
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [])
// 预计算排序后的子节点映射,避免每次渲染时重复计算
const sortedChildrenMap = useMemo(() => {
const map = new Map<string, string[]>()
Object.values(treeData.members).forEach(member => {
if (member.childrenIds && member.childrenIds.length > 0) {
const sorted = [...member.childrenIds].sort((aId, bId) => {
const a = treeData.members[aId]
const b = treeData.members[bId]
if (!a || !b) return 0
const aIsFounder = a.tags?.includes('始祖') || a.generation === 1
const bIsFounder = b.tags?.includes('始祖') || b.generation === 1
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
return (a.birthDate || "").localeCompare(b.birthDate || "")
})
map.set(member.id, sorted)
}
})
return map
}, [treeData.members])
if (!root) return null
// 递归渲染树节点 - 使用 memo 优化
const handleCreateNewRelation = (prefillName: string, relationType: RelationType) => {
if (!relationDialog) return
const params = new URLSearchParams()
// 分解姓名
const { surname, givenName } = parseName(prefillName)
if (surname) params.set("surname", surname)
if (givenName) params.set("givenName", givenName)
if (relationType === "child") {
params.set("generation", String(relationDialog.member.generation + 1))
if (relationDialog.member.gender === "MALE") {
params.set("fatherId", relationDialog.member.id)
if (relationDialog.member.spouseIds?.length) {
params.set("motherId", relationDialog.member.spouseIds[0])
}
} else if (relationDialog.member.gender === "FEMALE") {
params.set("motherId", relationDialog.member.id)
if (relationDialog.member.spouseIds?.length) {
params.set("fatherId", relationDialog.member.spouseIds[0])
}
}
} else if (relationType === "spouse") {
params.set("generation", String(relationDialog.member.generation))
params.set("spouseId", relationDialog.member.id)
params.set("gender", relationDialog.member.gender === "MALE" ? "FEMALE" : "MALE")
// 如果当前成员有子女,将他们也作为新配偶的子女
if (relationDialog.member.childrenIds && relationDialog.member.childrenIds.length > 0) {
relationDialog.member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
} else if (relationType === "father") {
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "MALE")
// 如果已有母亲,设为配偶
if (relationDialog.member.motherId) {
params.set("spouseId", relationDialog.member.motherId)
}
} else if (relationType === "mother") {
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "FEMALE")
// 如果已有父亲,设为配偶
if (relationDialog.member.fatherId) {
params.set("spouseId", relationDialog.member.fatherId)
}
}
const treeParams = new URLSearchParams(window.location.search)
treeParams.forEach((value, key) => {
params.set(key, value)
})
window.location.href = `/members/new?${params.toString()}`
}
// 准备根节点数据
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">
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
{/* 连线层 - 使用 SVG path 绘制(清晰且支持动画) */}
<svg
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 1, width: '100%', height: '100%', overflow: 'visible' }}
>
{connections.map((conn, index) => {
const d = `M ${conn.from.x} ${conn.from.y} L ${conn.to.x} ${conn.to.y}`
return (
<path
key={index}
d={d}
stroke="rgba(180, 83, 9, 0.7)"
strokeWidth={1}
fill="none"
style={{
transition: 'stroke-dashoffset 0.3s ease-out',
}}
/>
)
})}
</svg>
{/* 树节点层 */}
<div className="relative" style={{ zIndex: 10 }} key="tree-root">
<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>
{relationDialog && (
<AddRelationDialog
open={!!relationDialog}
onOpenChange={(open) => {
if (!open) setRelationDialog(null)
}}
member={relationDialog.member}
relationType={relationDialog.type}
onCreateNew={handleCreateNewRelation}
/>
)}
</div>
)
}