"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 } 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)
// 传统族谱初始定位到始祖并自适应缩放(如果有高亮成员则跳过)
useEffect(() => {
if (viewMode !== 'traditional' || hasInitialPositioned || !treeData.rootId) return
// 如果有高亮成员,跳过初始定位,让高亮定位逻辑处理
if (highlightedMemberId) {
setHasInitialPositioned(true)
return
}
// 找到始祖成员:优先使用标记为 isFounder 的成员,否则使用 rootId 对应的成员
const founderMember = Object.values(treeData.members).find(m => m.isFounder === true)
|| treeData.members[treeData.rootId]
if (!founderMember) return
// 延迟执行,等待 DOM 渲染完成
const timer = setTimeout(() => {
if (!containerRef.current) return
// 查找树内容容器(包含 TreeLayout 的 div)
const treeContent = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement
if (!treeContent) return
// 查找始祖节点的 DOM 元素
const founderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement
if (!founderNode) return
const containerRect = containerRef.current.getBoundingClientRect()
const treeRect = treeContent.getBoundingClientRect()
// 计算树的实际尺寸(当前缩放为1)
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) // 最大不超过1
const newScale = Math.max(fitScale, 0.2) // 最小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
// 计算需要的 x 偏移量使始祖居中
const offsetX = containerCenterX - nodeCenterX
setPosition({ x: offsetX, y: 0 })
setHasInitialPositioned(true)
}, 100)
}, 500)
return () => clearTimeout(timer)
}, [viewMode, treeData.rootId, treeData.members, hasInitialPositioned, highlightedMemberId])
// 切换视图模式时重置初始定位状态和缩放位置
useEffect(() => {
setHasInitialPositioned(false)
setScale(1)
setPosition({ x: 0, y: 0 })
}, [viewMode])
// 当有高亮成员时,定位到该成员并居中显示,设置 scale 为 1.0
useEffect(() => {
if (viewMode !== 'traditional' || !highlightedMemberId) return
// 第一步:重置位置和缩放为初始状态
setScale(1)
setPosition({ x: 0, y: 0 })
// 延迟执行,等待 DOM 完全渲染(需要足够长的时间让树完全展开)
const timer = setTimeout(() => {
if (!containerRef.current) {
return
}
// 查找高亮成员的 DOM 元素
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
// 此时 position 已经是 {x:0, y:0},所以节点位置就是相对于原点的位置
// 我们需要计算让节点居中的偏移量
const nodeCenterX = nodeRect.left - containerRect.left + nodeRect.width / 2
const nodeCenterY = nodeRect.top - containerRect.top + nodeRect.height / 2
// 计算偏移量
const offsetX = containerCenterX - nodeCenterX
const offsetY = containerCenterY - nodeCenterY
setPosition({ x: offsetX, y: offsetY })
// 30秒后清除高亮状态
setTimeout(() => {
setHighlightedMemberId(null)
}, 30000)
}, 1000) // 增加到1秒,确保DOM完全渲染
return () => clearTimeout(timer)
}, [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.1), 2))
}
// 计算两指间距离
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.1), 2))
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
const handleD3MemberClick = useCallback((id: string) => {
console.log('D3 onMemberClick triggered:', id, 'relationMode:', relationModeRef.current)
if (relationModeRef.current) {
console.log('Calling handleMemberClick, current selected:', selectedMembersRef.current)
// 直接操作 DOM 来显示选中标记
// D3 图表的节点在 SVG 的 foreignObject 中,需要特殊查询
// 注意:配偶没有独立的节点,需要在配偶区域显示选中标记
const updateSelectionMark = (memberId: string, selected: boolean) => {
// 首先尝试查找主成员节点
let nodeElement = document.querySelector(`[data-member-id="${memberId}"]`) as HTMLElement
let isSpouse = false
// 如果找不到,可能是配偶,尝试查找配偶区域
if (!nodeElement) {
const spouseArea = document.querySelector(`[data-spouse-id="${memberId}"]`) as HTMLElement
if (spouseArea) {
nodeElement = spouseArea
isSpouse = true
}
}
if (!nodeElement) {
console.log('Node element not found for:', memberId)
return
}
console.log('Updating selection mark for:', memberId, 'isSpouse:', isSpouse, 'selected:', selected)
if (selected) {
// 只添加打钩标记,不改变边框和背景
if (!nodeElement.querySelector('.selection-mark')) {
const checkMark = document.createElement('div')
checkMark.className = 'selection-mark'
if (isSpouse) {
// 配偶区域打钩位置(右边)
checkMark.style.cssText = 'position: absolute; top: -6px; right: -6px; z-index: 20;'
nodeElement.style.position = 'relative'
} else {
// 主成员打钩位置(左边)
checkMark.style.cssText = 'position: absolute; top: 6px; left: 6px; z-index: 20;'
}
checkMark.innerHTML = ``
nodeElement.appendChild(checkMark)
}
} else {
// 移除选中标记
const mark = nodeElement.querySelector('.selection-mark')
if (mark) mark.remove()
}
}
// 直接处理选择逻辑
if (selectedMembersRef.current.length === 0) {
// 选择第一个成员
updateSelectionMark(id, true)
setSelectedMembers([id])
} else if (selectedMembersRef.current.length === 1) {
const [firstId] = selectedMembersRef.current
if (firstId === id) {
// 点击同一个成员,取消选择
updateSelectionMark(id, false)
setSelectedMembers([])
return
}
// 选择第二个成员,显示选中标记
updateSelectionMark(id, true)
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(() => {
// 清除所有选中标记
document.querySelectorAll('.selection-mark').forEach(mark => mark.remove())
setSelectedMembers([])
setRelationMode(false)
}, 300)
}
} else {
console.log('Navigating to member detail')
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 null
}
// 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 - 移动端底部横向,桌面端左侧纵向 */}
{/* Tree Canvas */}
{/* 缩略图导航 */}
) : (
{/* 统一的控制按钮 - 移动端底部横向,桌面端左侧纵向 */}
)}
{/* 关系查询结果对话框 */}
)
}