Files
chinese-family-tree-2/app/tree/page.tsx
T
selfrelease ce06674cba perf: 传统族谱视图全面优化
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 动画
2026-07-16 15:45:24 +08:00

941 lines
36 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 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: () => (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
)
}
)
// 导入类型
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<ViewMode>("traditional")
const [isIOSDevice, setIsIOSDevice] = useState(false)
// 关系查询模式
const [relationMode, setRelationMode] = useState(false)
const [selectedMembers, setSelectedMembers] = useState<string[]>([])
const [relationResult, setRelationResult] = useState<any>(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<HTMLDivElement>(null)
const d3ChartRef = useRef<D3OrgChartRef>(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<HTMLDivElement | null>).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 (
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</div>
</div>
)
}
// Handle root not found
if (!treeData.rootId) {
return (
<div className="min-h-screen bg-background flex flex-col">
<SiteHeader />
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 text-primary/80">
<Network className="h-16 w-16" strokeWidth={1} />
</div>
<p className="text-base text-foreground font-light tracking-wide"></p>
</div>
</div>
</div>
)
}
return (
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
{/* 视图切换和关系查询按钮 - 移动端自适应 */}
<div className="absolute top-16 md:top-20 right-2 md:right-4 z-30 flex gap-1 md:gap-2">
{/* 关系查询按钮 */}
<Button
variant={relationMode ? "default" : "outline"}
className="gap-1 md:gap-2 shadow-lg text-xs md:text-sm px-2 md:px-4 h-8 md:h-10"
onClick={toggleRelationMode}
>
<Users className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">
{relationMode ? (
selectedMembers.length === 0 ? '选择第一个成员' : '选择第二个成员'
) : '关系查询'}
</span>
<span className="sm:hidden">
{relationMode ? '选择' : '查询'}
</span>
</Button>
{/* iOS 设备只显示传统族谱,不显示切换按钮 */}
{!isIOSDevice && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-1 md:gap-2 shadow-lg text-xs md:text-sm px-2 md:px-4 h-8 md:h-10">
<LayoutGrid className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">{viewMode === 'traditional' ? '传统族谱' : 'D3族谱'}</span>
<span className="sm:hidden">{viewMode === 'traditional' ? '传统' : 'D3'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => {
const params = new URLSearchParams(searchParams.toString())
params.set('view', 'traditional')
router.push(`/tree?${params.toString()}`)
}}
className={viewMode === 'traditional' ? 'bg-accent' : ''}
>
<div className="flex items-center justify-between w-full">
<span></span>
{viewMode === 'traditional' && <span className="text-primary"></span>}
</div>
</DropdownMenuItem>
{/* iOS 设备不显示 D3 选项(foreignObject 渲染问题) */}
{!isIOSDevice && (
<DropdownMenuItem
onClick={() => {
const params = new URLSearchParams(searchParams.toString())
params.set('view', 'd3-org-chart')
router.push(`/tree?${params.toString()}`)
}}
className={viewMode === 'd3-org-chart' ? 'bg-accent' : ''}
>
<div className="flex items-center justify-between w-full">
<span>D3族谱</span>
{viewMode === 'd3-org-chart' && <span className="text-primary"></span>}
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{viewMode === 'traditional' ? (
<div className="flex-1 relative min-h-0">
{/* Toolbar - 移动端底部横向,桌面端左侧纵向 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 md:bottom-auto md:top-4 md:left-4 md:translate-x-0 z-20 flex flex-row md:flex-col gap-1 md:gap-2 bg-background/90 backdrop-blur p-1.5 md:p-2 rounded-lg border shadow-lg">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.min(s + 0.1, 3))}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.max(s - 0.1, 0.15))}>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-[10px] text-muted-foreground text-center tabular-nums select-none px-1">{Math.round(scale * 100)}%</span>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleTraditionalFitView} title="自适应视图">
<Move className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setExpandAll(true)} title="完全展开">
<Maximize2 className="h-4 w-4" />
</Button>
<div className="w-px md:w-auto h-6 md:h-px bg-border mx-1 md:mx-0 md:my-1" />
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleExportImage} title="导出为图片">
<Download className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleExportPDF} title="导出族谱PDF">
<FileText className="h-4 w-4" />
</Button>
</div>
{/* Tree Canvas */}
<div
ref={containerRefCallback}
className="w-full h-full overflow-hidden cursor-move relative touch-none bg-[url('/rice-paper.png')] bg-repeat select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: "top center",
transition: isDragging ? "none" : "transform 0.1s ease-out",
}}
className="absolute min-w-full min-h-full flex justify-center pt-20 pb-20"
>
<TreeLayout
rootId={treeData.rootId}
relationMode={relationMode}
selectedMembers={selectedMembers}
onMemberClick={handleMemberClick}
expandAll={expandAll}
onExpandAllChange={setExpandAll}
scale={scale}
/>
</div>
</div>
{/* 缩略图导航 */}
<Minimap
containerRef={containerRef}
scale={scale}
position={position}
onPositionChange={setPosition}
viewMode="traditional"
/>
</div>
) : (
<div className="flex-1 relative min-h-0">
{/* 统一的控制按钮 - 移动端底部横向,桌面端左侧纵向 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 md:bottom-auto md:top-4 md:left-4 md:translate-x-0 z-20 flex flex-row md:flex-col gap-1 md:gap-2 bg-background/90 backdrop-blur p-1.5 md:p-2 rounded-lg border shadow-lg">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.expandAll()} title="完全展开">
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.collapseAll()} title="折叠全部">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.fitView()} title="适应视图">
<Move className="h-4 w-4" />
</Button>
<div className="w-px md:w-auto h-6 md:h-px bg-border mx-1 md:mx-0 md:my-1" />
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.exportPNG()} title="导出PNG">
<Download className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleExportPDF} title="导出族谱PDF">
<FileText className="h-4 w-4" />
</Button>
</div>
<div className="w-full h-full">
<D3OrgChartFlow
ref={d3ChartRef}
members={treeData.members}
rootId={treeData.rootId}
relationMode={relationMode}
selectedMembers={selectedMembers}
onMemberClick={handleD3MemberClick}
/>
</div>
</div>
)}
{/* 关系查询结果对话框 */}
<Dialog open={showRelationDialog} onOpenChange={handleDialogClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<p id="relation-description" className="sr-only"></p>
{relationResult && (
<div className="space-y-4">
{/* 成员信息 */}
<div className="flex items-center justify-center gap-4 p-6 bg-muted rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold font-serif mb-1">
<MemberNameWithStatus
name={relationResult.from.fullName}
isDead={!!relationResult.from.deathDate}
/>
</div>
<div className="text-sm text-muted-foreground">
{relationResult.from.generation}
</div>
</div>
<div className="flex flex-col items-center gap-2">
<Badge variant="default" className="text-lg px-4 py-2">
{relationResult.relationship.term}
</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold font-serif mb-1">
<MemberNameWithStatus
name={relationResult.to.fullName}
isDead={!!relationResult.to.deathDate}
/>
</div>
<div className="text-sm text-muted-foreground">
{relationResult.to.generation}
</div>
</div>
</div>
{/* 关系说明 */}
<div id="relation-description" className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<div className="text-sm font-medium mb-1"></div>
<div className="text-muted-foreground">
<strong><MemberNameWithStatus name={relationResult.from.fullName} isDead={!!relationResult.from.deathDate} /></strong> <strong><MemberNameWithStatus name={relationResult.to.fullName} isDead={!!relationResult.to.deathDate} /></strong> <strong className="text-primary">{relationResult.relationship.term}</strong>
</div>
</div>
{/* 关系路径 */}
{relationResult.relationship.path && (
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm font-medium mb-2"></div>
<RelationshipPathDisplay
path={relationResult.relationship.path}
className="text-xs"
/>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}