"use client" import type React from "react" import dynamic from "next/dynamic" import { SiteHeader } from "@/components/site-header" import { TreeLayout } from "@/components/tree/tree-layout" import { useFamily } from "@/context/family-context" import { useRelationship } from "@/hooks/use-relationship" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users, Network, Loader2, Maximize2, FileText } from "lucide-react" import { useState, useRef, useEffect, useCallback, useLayoutEffect } from "react" import { useRouter, useSearchParams } from "next/navigation" import { MemberNameWithStatus } from "@/components/member-name-with-status" import { RelationshipPathDisplay } from "@/components/relationship-path-display" import { useSession } from "next-auth/react" import { EmptyStateBadge } from "@/components/empty-state-badge" import { exportFamilyHTML } from "@/lib/export-pdf" import { useDialog } from "@/components/ui/alert-dialog-custom" import { Minimap } from "@/components/tree/minimap" // 动态导入 D3 图表组件(减少初始加载体积) const D3OrgChartFlow = dynamic( () => import("@/components/tree/d3-org-chart-flow").then(mod => ({ default: mod.D3OrgChartFlow })), { ssr: false, loading: () => (
加载图表组件...
) } ) // 导入类型 import type { D3OrgChartRef } from "@/components/tree/d3-org-chart-flow" type ViewMode = "traditional" | "d3-org-chart" export default function TreePage() { const { treeData, currentTree, refreshData, highlightedMemberId, setHighlightedMemberId } = useFamily() const { calculateRelationship } = useRelationship() const { data: session, status } = useSession() const { showAlert } = useDialog() const router = useRouter() const searchParams = useSearchParams() const [viewMode, setViewMode] = useState("traditional") const [isIOSDevice, setIsIOSDevice] = useState(false) // 关系查询模式 const [relationMode, setRelationMode] = useState(false) const [selectedMembers, setSelectedMembers] = useState([]) const [relationResult, setRelationResult] = useState(null) const [showRelationDialog, setShowRelationDialog] = useState(false) // 使用ref存储最新的relationMode值,避免回调重新创建 const relationModeRef = useRef(relationMode) const selectedMembersRef = useRef(selectedMembers) useEffect(() => { relationModeRef.current = relationMode selectedMembersRef.current = selectedMembers }, [relationMode, selectedMembers]) // 未登录时重定向到登录页 useEffect(() => { if (status === 'unauthenticated') { router.push('/auth/signin') } }, [status, router]) // 页面加载时刷新数据 useEffect(() => { if (currentTree?.id) { refreshData() } }, [currentTree?.id, refreshData]) // 从 URL 参数读取视图模式,iOS 触摸设备默认使用传统族谱 useEffect(() => { if (!searchParams) return const view = searchParams.get('view') // 检测 iOS 触摸设备(iPad/iPhone 上的所有浏览器) const isTouchDevice = typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1 const isIOS = typeof navigator !== 'undefined' && ( /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && isTouchDevice) || /CriOS/.test(navigator.userAgent) // iOS Chrome ) // 设置 iOS 设备状态 setIsIOSDevice(isIOS) if (view === 'd3-org-chart' && !isIOS) { // 非 iOS 设备可以使用 D3 setViewMode(view) } else if (view === 'traditional' || isIOS) { // iOS 设备强制使用传统族谱 setViewMode('traditional') } }, [searchParams]) const [scale, setScale] = useState(1) const [position, setPosition] = useState({ x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) const [startPos, setStartPos] = useState({ x: 0, y: 0 }) const [dragStartPos, setDragStartPos] = useState({ x: 0, y: 0 }) const [expandAll, setExpandAll] = useState(false) const [hasInitialPositioned, setHasInitialPositioned] = useState(false) const containerRef = useRef(null) const d3ChartRef = useRef(null) // 传统族谱初始定位到始祖并自适应缩放(使用 useLayoutEffect + rAF 替代 600ms setTimeout) useLayoutEffect(() => { if (viewMode !== 'traditional' || hasInitialPositioned || !treeData.rootId) return if (highlightedMemberId) { setHasInitialPositioned(true) return } const founderMember = Object.values(treeData.members).find(m => m.isFounder === true) || treeData.members[treeData.rootId] if (!founderMember) return // 双 rAF 确保浏览器完成布局后再计算(~32ms vs 之前 600ms) let raf2 = 0 const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { if (!containerRef.current) return const treeContent = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement if (!treeContent) return const founderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement if (!founderNode) return const containerRect = containerRef.current.getBoundingClientRect() const treeRect = treeContent.getBoundingClientRect() const treeWidth = treeRect.width const treeHeight = treeRect.height const padding = 40 const scaleX = (containerRect.width - padding * 2) / treeWidth const scaleY = (containerRect.height - padding * 2) / treeHeight const fitScale = Math.min(scaleX, scaleY, 1) const newScale = Math.max(fitScale, 0.15) setScale(newScale) // 下一帧计算居中位置 requestAnimationFrame(() => { if (!containerRef.current) return const updatedFounderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement if (!updatedFounderNode) return const updatedContainerRect = containerRef.current.getBoundingClientRect() const updatedNodeRect = updatedFounderNode.getBoundingClientRect() const containerCenterX = updatedContainerRect.width / 2 const nodeCenterX = updatedNodeRect.left - updatedContainerRect.left + updatedNodeRect.width / 2 const offsetX = containerCenterX - nodeCenterX setPosition({ x: offsetX, y: 0 }) setHasInitialPositioned(true) }) }) }) return () => { cancelAnimationFrame(raf1) if (raf2) cancelAnimationFrame(raf2) } }, [viewMode, treeData.rootId, treeData.members, hasInitialPositioned, highlightedMemberId]) // 切换视图模式时重置初始定位状态和缩放位置 useEffect(() => { setHasInitialPositioned(false) setScale(1) setPosition({ x: 0, y: 0 }) }, [viewMode]) // 当有高亮成员时,定位到该成员并居中显示(使用 rAF 替代 1000ms setTimeout) useLayoutEffect(() => { if (viewMode !== 'traditional' || !highlightedMemberId) return setScale(1) setPosition({ x: 0, y: 0 }) let raf2 = 0 let raf3 = 0 const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { raf3 = requestAnimationFrame(() => { if (!containerRef.current) return const highlightedNode = containerRef.current.querySelector(`[data-member-id="${highlightedMemberId}"]`) as HTMLElement if (!highlightedNode) return const containerRect = containerRef.current.getBoundingClientRect() const nodeRect = highlightedNode.getBoundingClientRect() const containerCenterX = containerRect.width / 2 const containerCenterY = containerRect.height / 2 const nodeCenterX = nodeRect.left - containerRect.left + nodeRect.width / 2 const nodeCenterY = nodeRect.top - containerRect.top + nodeRect.height / 2 setPosition({ x: containerCenterX - nodeCenterX, y: containerCenterY - nodeCenterY }) setTimeout(() => setHighlightedMemberId(null), 30000) }) }) }) return () => { cancelAnimationFrame(raf1) if (raf2) cancelAnimationFrame(raf2) if (raf3) cancelAnimationFrame(raf3) } }, [viewMode, highlightedMemberId, setHighlightedMemberId]) // 传统族谱自适应视图函数 const handleTraditionalFitView = useCallback(() => { if (!containerRef.current || !treeData.rootId) return // 找到始祖成员 const founderMember = Object.values(treeData.members).find(m => m.isFounder === true) || treeData.members[treeData.rootId] if (!founderMember) return // 第一步:重置到标准状态 (scale=1, position=0) setScale(1) setPosition({ x: 0, y: 0 }) // 第二步:等待 DOM 更新后计算 setTimeout(() => { if (!containerRef.current) return // 查找树内容容器 const treeContent = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement if (!treeContent) return // 查找始祖节点 const founderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement if (!founderNode) return const containerRect = containerRef.current.getBoundingClientRect() const treeRect = treeContent.getBoundingClientRect() // 此时 scale=1,所以 treeRect 就是实际尺寸 const treeWidth = treeRect.width const treeHeight = treeRect.height // 计算适合容器的缩放比例 const padding = 40 const scaleX = (containerRect.width - padding * 2) / treeWidth const scaleY = (containerRect.height - padding * 2) / treeHeight const fitScale = Math.min(scaleX, scaleY, 1) const newScale = Math.max(fitScale, 0.2) // 设置新缩放 setScale(newScale) // 第三步:等待缩放生效后计算始祖居中位置 setTimeout(() => { if (!containerRef.current) return const updatedFounderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement if (!updatedFounderNode) return const updatedContainerRect = containerRef.current.getBoundingClientRect() const updatedNodeRect = updatedFounderNode.getBoundingClientRect() // 计算始祖节点居中的偏移 const containerCenterX = updatedContainerRect.width / 2 const nodeCenterX = updatedNodeRect.left - updatedContainerRect.left + updatedNodeRect.width / 2 const offsetX = containerCenterX - nodeCenterX setPosition({ x: offsetX, y: 0 }) }, 100) }, 100) }, [treeData.rootId, treeData.members]) const handleMouseDown = (e: React.MouseEvent) => { // 如果点击的是节点卡片,不启动拖拽 const target = e.target as HTMLElement if (target.closest('.family-node-card')) { return } setIsDragging(true) setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y }) setDragStartPos({ x: e.clientX, y: e.clientY }) } const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging) return // 只有移动超过5px才算拖拽,避免误触 const deltaX = Math.abs(e.clientX - dragStartPos.x) const deltaY = Math.abs(e.clientY - dragStartPos.y) if (deltaX < 5 && deltaY < 5) return setPosition({ x: e.clientX - startPos.x, y: e.clientY - startPos.y, }) } const handleMouseUp = () => { setIsDragging(false) } // 触摸事件状态 const touchStartRef = useRef<{ x: number; y: number; distance?: number } | null>(null) const positionRef = useRef(position) const scaleRef = useRef(scale) // 保持 ref 同步 useEffect(() => { positionRef.current = position }, [position]) useEffect(() => { scaleRef.current = scale }, [scale]) // 滚轮和触摸事件处理函数 - 使用 useCallback 确保稳定引用 const bindWheelEvents = useCallback((container: HTMLDivElement | null) => { if (!container) return // 清理之前的事件监听器 const cleanup = (container as any).__wheelCleanup if (cleanup) { cleanup() delete (container as any).__wheelCleanup } // 鼠标滚轮缩放 const handleWheel = (e: WheelEvent) => { e.preventDefault() const delta = e.deltaY > 0 ? -0.1 : 0.1 setScale((s) => Math.min(Math.max(s + delta, 0.15), 3)) } // 计算两指间距离 const getTouchDistance = (touches: TouchList) => { if (touches.length < 2) return 0 const dx = touches[0].clientX - touches[1].clientX const dy = touches[0].clientY - touches[1].clientY return Math.sqrt(dx * dx + dy * dy) } // 触摸开始 const handleTouchStart = (e: globalThis.TouchEvent) => { if (e.touches.length === 1) { touchStartRef.current = { x: e.touches[0].clientX - positionRef.current.x, y: e.touches[0].clientY - positionRef.current.y } } else if (e.touches.length === 2) { touchStartRef.current = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2, distance: getTouchDistance(e.touches) } } } // 触摸移动 const handleTouchMove = (e: globalThis.TouchEvent) => { if (!touchStartRef.current) return e.preventDefault() if (e.touches.length === 1 && !touchStartRef.current.distance) { const newX = e.touches[0].clientX - touchStartRef.current.x const newY = e.touches[0].clientY - touchStartRef.current.y setPosition({ x: newX, y: newY }) } else if (e.touches.length === 2 && touchStartRef.current.distance) { const newDistance = getTouchDistance(e.touches) const scaleFactor = newDistance / touchStartRef.current.distance setScale(s => Math.min(Math.max(s * scaleFactor, 0.15), 3)) touchStartRef.current.distance = newDistance } } // 触摸结束 const handleTouchEnd = () => { touchStartRef.current = null } // 添加事件监听器,设置 passive: false container.addEventListener('wheel', handleWheel, { passive: false }) container.addEventListener('touchstart', handleTouchStart, { passive: true }) container.addEventListener('touchmove', handleTouchMove, { passive: false }) container.addEventListener('touchend', handleTouchEnd, { passive: true }) // 保存清理函数 ;(container as any).__wheelCleanup = () => { container.removeEventListener('wheel', handleWheel) container.removeEventListener('touchstart', handleTouchStart) container.removeEventListener('touchmove', handleTouchMove) container.removeEventListener('touchend', handleTouchEnd) } }, []) // 使用 ref callback 在 DOM 挂载时绑定事件 const containerRefCallback = useCallback((node: HTMLDivElement | null) => { // 更新 ref (containerRef as React.MutableRefObject).current = node // 绑定事件 if (node && viewMode === 'traditional') { bindWheelEvents(node) } }, [viewMode, bindWheelEvents]) // 清理事件监听器 useEffect(() => { return () => { const container = containerRef.current if (container && (container as any).__wheelCleanup) { (container as any).__wheelCleanup() delete (container as any).__wheelCleanup } } }, []) // 处理成员选择(关系查询模式) const handleMemberClick = (memberId: string) => { if (selectedMembers.length === 0) { // 选择第一个成员 setSelectedMembers([memberId]) } else if (selectedMembers.length === 1) { // 选择第二个成员,计算关系 const [firstId] = selectedMembers if (firstId === memberId) { // 点击同一个人,取消选择 setSelectedMembers([]) return } const result = calculateRelationship(firstId, memberId) const firstMember = treeData.members[firstId] const secondMember = treeData.members[memberId] setRelationResult({ from: firstMember, to: secondMember, relationship: result }) setShowRelationDialog(true) setSelectedMembers([]) setRelationMode(false) } } // 切换关系查询模式 const toggleRelationMode = () => { setRelationMode(!relationMode) setSelectedMembers([]) setRelationResult(null) } // 处理对话框关闭 const handleDialogClose = (open: boolean) => { setShowRelationDialog(open) if (!open && relationResult) { // 清除选中样式 const clearNodeStyle = (nodeId: string) => { const chartContainer = document.querySelector('.w-full.h-full.bg-background.rounded-lg.border') const svg = chartContainer?.querySelector('svg') if (!svg) return const foreignObjects = svg.querySelectorAll('foreignObject') foreignObjects.forEach((fo: any) => { const nodeData = (fo.parentNode as any)?.__data__?.data if (nodeData?.id === nodeId) { const nameDiv = fo.querySelector('div[style*="font-size: 13px"]') as HTMLElement if (nameDiv) { // 移除勾选图标 const checkIcon = nameDiv.querySelector('svg') if (checkIcon) { checkIcon.remove() } } } }) } clearNodeStyle(relationResult.from.id) clearNodeStyle(relationResult.to.id) } } // 使用useCallback创建稳定的回调,通过ref访问最新的relationMode(移除直接 DOM 操作) const handleD3MemberClick = useCallback((id: string) => { if (relationModeRef.current) { if (selectedMembersRef.current.length === 0) { setSelectedMembers([id]) } else if (selectedMembersRef.current.length === 1) { const [firstId] = selectedMembersRef.current if (firstId === id) { setSelectedMembers([]) return } setSelectedMembers([firstId, id]) const result = calculateRelationship(firstId, id) const firstMember = treeData.members[firstId] const secondMember = treeData.members[id] setRelationResult({ from: firstMember, to: secondMember, relationship: result }) setShowRelationDialog(true) setTimeout(() => { setSelectedMembers([]) setRelationMode(false) }, 300) } } else { const params = new URLSearchParams(window.location.search) router.push(`/members/${id}?${params.toString()}`) } }, [router, calculateRelationship, treeData.members]) const handleExportImage = async () => { if (!containerRef.current) return try { // 临时重置位置和缩放以获取完整视图 const originalPosition = { ...position } const originalScale = scale setPosition({ x: 0, y: 0 }) setScale(1) // 等待 DOM 更新 await new Promise(resolve => setTimeout(resolve, 800)) // 找到实际的族谱容器(包含 TreeLayout 的 div) const treeContainer = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement if (!treeContainer) { throw new Error('找不到族谱容器') } // 查找所有连接线(使用更宽泛的选择器) const allDivs = treeContainer.querySelectorAll('div') const lineElements: HTMLElement[] = [] allDivs.forEach(div => { const el = div as HTMLElement const classes = String(el.className || '') // 查找包含 bg-foreground 的元素(连接线) if (classes.includes('bg-foreground')) { lineElements.push(el) } }) // 克隆容器用于导出 const clonedContainer = treeContainer.cloneNode(true) as HTMLElement // 在克隆的容器中找到对应的连接线并设置样式 const clonedDivs = clonedContainer.querySelectorAll('div') let lineIndex = 0 clonedDivs.forEach(div => { const el = div as HTMLElement const classes = String(el.className || '') if (classes.includes('bg-foreground') && lineElements[lineIndex]) { const original = lineElements[lineIndex] const computed = window.getComputedStyle(original) // 直接设置内联样式 el.style.backgroundColor = computed.backgroundColor el.style.width = computed.width el.style.height = computed.height el.style.position = 'absolute' el.style.left = computed.left el.style.top = computed.top lineIndex++ } }) // 移除卡片边框 clonedContainer.querySelectorAll('*').forEach((child) => { const el = child as HTMLElement const classes = String(el.className || '') // 跳过连接线和连接线容器 if (classes.includes('bg-foreground') || classes.includes('pointer-events-none')) { return } // 移除其他元素的边框 el.style.border = 'none' el.style.outline = 'none' }) // 临时添加到 DOM clonedContainer.style.position = 'fixed' clonedContainer.style.left = '-9999px' clonedContainer.style.top = '0' document.body.appendChild(clonedContainer) // 等待渲染 await new Promise(resolve => setTimeout(resolve, 200)) // 动态导入 dom-to-image-more(仅在客户端) const domtoimage = await import('dom-to-image-more') // 使用 toBlob 方法 const blob = await domtoimage.default.toBlob(clonedContainer, { quality: 0.95, bgcolor: '#ffffff' }) // 清理克隆的容器 document.body.removeChild(clonedContainer) // 恢复原始位置和缩放 setPosition(originalPosition) setScale(originalScale) // 创建下载链接 const url = URL.createObjectURL(blob) const link = document.createElement('a') link.download = `family-tree-${new Date().toISOString().split('T')[0]}.png` link.href = url link.click() // 清理 setTimeout(() => URL.revokeObjectURL(url), 100) } catch (error) { console.error('导出图片失败:', error) } } // 导出 PDF(按世代导出成员信息) const handleExportPDF = useCallback(async () => { if (!treeData.members || Object.keys(treeData.members).length === 0) { await showAlert('没有成员数据可导出', "提示") return } try { const members = Object.values(treeData.members) const getMemberName = (id: string) => treeData.members[id]?.fullName || '' const treeName = currentTree?.name || '家族' // 获取当前页面的基础 URL const baseUrl = window.location.origin await exportFamilyHTML(members, getMemberName, treeName, baseUrl) } catch (error) { console.error('导出PDF失败:', error) await showAlert('导出失败,请重试', "错误") } }, [treeData.members, currentTree?.name, showAlert]) // 未登录时不渲染内容(已在 useEffect 中重定向) if (status === 'loading' || !session) { return (
加载中...
) } // Handle root not found if (!treeData.rootId) { return (

谱系待续

) } return (
{/* 视图切换和关系查询按钮 - 移动端自适应 */}
{/* 关系查询按钮 */} {/* iOS 设备只显示传统族谱,不显示切换按钮 */} {!isIOSDevice && ( { const params = new URLSearchParams(searchParams.toString()) params.set('view', 'traditional') router.push(`/tree?${params.toString()}`) }} className={viewMode === 'traditional' ? 'bg-accent' : ''} >
传统族谱 {viewMode === 'traditional' && }
{/* iOS 设备不显示 D3 选项(foreignObject 渲染问题) */} {!isIOSDevice && ( { const params = new URLSearchParams(searchParams.toString()) params.set('view', 'd3-org-chart') router.push(`/tree?${params.toString()}`) }} className={viewMode === 'd3-org-chart' ? 'bg-accent' : ''} >
D3族谱 {viewMode === 'd3-org-chart' && }
)}
)}
{viewMode === 'traditional' ? (
{/* Toolbar - 移动端底部横向,桌面端左侧纵向 */}
{Math.round(scale * 100)}%
{/* Tree Canvas */}
{/* 缩略图导航 */}
) : (
{/* 统一的控制按钮 - 移动端底部横向,桌面端左侧纵向 */}
)} {/* 关系查询结果对话框 */} 关系查询结果

显示两个家族成员之间的关系

{relationResult && (
{/* 成员信息 */}
第{relationResult.from.generation}世
{relationResult.relationship.term}
第{relationResult.to.generation}世
{/* 关系说明 */}
关系说明:
{relationResult.relationship.term}
{/* 关系路径 */} {relationResult.relationship.path && (
关系路径:
)}
)}
) }