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 动画
941 lines
36 KiB
TypeScript
941 lines
36 KiB
TypeScript
"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>
|
||
)
|
||
}
|