ce06674cba
P0 性能优化: - 连线计算用 requestAnimationFrame + useLayoutEffect 替代 7个 setTimeout 轮询 - 节点注册改为批量 ref 模式,消除 N 次 setState 重渲染 - 初始定位用 useLayoutEffect + 双 rAF 替代 600ms setTimeout - 高亮定位用三 rAF 替代 1000ms setTimeout - 使用 ResizeObserver 替代 window.resize 监听 P1 视觉优化: - 连线从 div 改为 SVG path 绘制,清晰且支持动画 - 背景纹理图从 transparenttextures.com 本地化到 public/ - 添加骨架屏 loading 状态替代 return null P2 代码质量: - 移除 page.tsx 中 5 处 console.log - 移除 family-node.tsx 中 3 处 console.log - 消除 handleD3MemberClick 中的直接 DOM 操作(createElement/innerHTML) - parseName 提取为模块级函数,避免每次渲染重建 P3 增强: - 缩放范围从 0.1~2 调整为 0.15~3 - 工具栏显示当前缩放比例 - SVG 连线添加 transition 动画
413 lines
16 KiB
TypeScript
413 lines
16 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, 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])
|
||
|
||
// 计算所有连线位置(使用 requestAnimationFrame 批量计算,避免 layout thrashing)
|
||
const rafIdRef = useRef<number | null>(null)
|
||
const calculateConnections = useCallback(() => {
|
||
if (rafIdRef.current !== null) {
|
||
cancelAnimationFrame(rafIdRef.current)
|
||
}
|
||
rafIdRef.current = requestAnimationFrame(() => {
|
||
rafIdRef.current = null
|
||
const newConnections: Connection[] = []
|
||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||
if (!containerRect || nodeRefs.current.size === 0) return
|
||
|
||
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 = nodeElement.getBoundingClientRect()
|
||
if (parentRect.width === 0 || parentRect.height === 0) return
|
||
|
||
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 || !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])
|
||
|
||
// 使用 useLayoutEffect 在 DOM 更新后立即计算连线(替代 7 个 setTimeout)
|
||
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>
|
||
)
|
||
}
|