499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
"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 } 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'
|
|
}
|
|
|
|
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 // 连接线垂直高度
|
|
|
|
// 节点注册计数,用于触发连线重新计算
|
|
const [nodeRegisteredCount, setNodeRegisteredCount] = useState(0)
|
|
|
|
// 折叠状态管理:存储被折叠的节点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)
|
|
// 触发连线重新计算
|
|
setNodeRegisteredCount(prev => prev + 1)
|
|
} 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])
|
|
|
|
// 计算所有连线位置
|
|
const calculateConnections = useCallback(() => {
|
|
const newConnections: Connection[] = []
|
|
const containerRect = containerRef.current?.getBoundingClientRect()
|
|
if (!containerRect) {
|
|
return
|
|
}
|
|
|
|
// 检查是否有足够的节点已注册
|
|
if (nodeRefs.current.size === 0) {
|
|
return
|
|
}
|
|
|
|
let processedCount = 0
|
|
let skippedNoChildren = 0
|
|
let skippedCollapsed = 0
|
|
let skippedHidden = 0
|
|
let skippedNotInDom = 0
|
|
let skippedZeroSize = 0
|
|
|
|
nodeRefs.current.forEach((nodeElement, nodeId) => {
|
|
const member = getMember(nodeId)
|
|
if (!member || !member.childrenIds || member.childrenIds.length === 0) {
|
|
skippedNoChildren++
|
|
return
|
|
}
|
|
|
|
// 如果该节点被折叠,不绘制到子节点的连线
|
|
if (collapsedNodes.has(nodeId)) {
|
|
skippedCollapsed++
|
|
return
|
|
}
|
|
|
|
// 如果该节点被祖先折叠隐藏,跳过
|
|
if (isNodeHiddenByCollapse(nodeId)) {
|
|
skippedHidden++
|
|
return
|
|
}
|
|
|
|
// 检查元素是否仍在 DOM 中且可见
|
|
if (!document.body.contains(nodeElement)) {
|
|
skippedNotInDom++
|
|
return
|
|
}
|
|
const parentRect = nodeElement.getBoundingClientRect()
|
|
// 如果元素不可见(宽高为0),跳过
|
|
if (parentRect.width === 0 || parentRect.height === 0) {
|
|
skippedZeroSize++
|
|
return
|
|
}
|
|
|
|
processedCount++
|
|
|
|
// 考虑缩放因素: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) / scale,
|
|
topY: (childRect.top - containerRect.top) / scale
|
|
}
|
|
})
|
|
.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, scale])
|
|
|
|
// 折叠状态变化时重新计算连线
|
|
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))
|
|
timers.push(setTimeout(calculateConnections, 1000))
|
|
return () => timers.forEach(timer => clearTimeout(timer))
|
|
}, [collapsedNodes, calculateConnections])
|
|
|
|
// 缩放变化时重新计算连线
|
|
useEffect(() => {
|
|
calculateConnections()
|
|
}, [scale, calculateConnections])
|
|
|
|
// 节点注册变化时重新计算连线(防抖)
|
|
useEffect(() => {
|
|
if (nodeRegisteredCount === 0) return
|
|
const timer = setTimeout(calculateConnections, 100)
|
|
return () => clearTimeout(timer)
|
|
}, [nodeRegisteredCount, calculateConnections])
|
|
|
|
useEffect(() => {
|
|
// 多次尝试计算,确保 DOM 完全渲染
|
|
// 对于大型族谱(100+人),需要更长的延迟
|
|
const timers: NodeJS.Timeout[] = []
|
|
timers.push(setTimeout(calculateConnections, 100))
|
|
timers.push(setTimeout(calculateConnections, 300))
|
|
timers.push(setTimeout(calculateConnections, 500))
|
|
timers.push(setTimeout(calculateConnections, 1000))
|
|
timers.push(setTimeout(calculateConnections, 2000))
|
|
timers.push(setTimeout(calculateConnections, 3000))
|
|
timers.push(setTimeout(calculateConnections, 5000))
|
|
|
|
// 监听窗口大小变化
|
|
window.addEventListener('resize', calculateConnections)
|
|
|
|
return () => {
|
|
timers.forEach(timer => clearTimeout(timer))
|
|
window.removeEventListener('resize', calculateConnections)
|
|
}
|
|
}, [calculateConnections, treeData])
|
|
|
|
|
|
// 预计算排序后的子节点映射,避免每次渲染时重复计算
|
|
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
|
|
|
|
// 姓名分解函数:将全名分解为姓和名
|
|
const parseName = (fullName: string): { surname: string; givenName: string } => {
|
|
const name = fullName.trim()
|
|
if (!name) return { surname: '', givenName: '' }
|
|
|
|
// 常见复姓列表
|
|
const compoundSurnames = [
|
|
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
|
|
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
|
|
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
|
|
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
|
|
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
|
|
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
|
|
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
|
|
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
|
|
]
|
|
|
|
// 检查是否是复姓
|
|
for (const compound of compoundSurnames) {
|
|
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: '' }
|
|
}
|
|
|
|
// 递归渲染树节点 - 使用 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%' }}>
|
|
{/* 连线层 - 使用 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"
|
|
style={{
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
backgroundColor: 'rgba(180, 83, 9, 0.7)',
|
|
transform: isHorizontal ? 'scaleY(0.5)' : 'scaleX(0.5)',
|
|
transformOrigin: 'top left',
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* 树节点层 */}
|
|
<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>
|
|
)
|
|
}
|