Files
chinese-family-tree-2/app/tree/page.tsx
T
freedakgmail 2b4bc92f1b 0.0.3.0
2025-11-23 13:31:06 +08:00

311 lines
11 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 { SiteHeader } from "@/components/site-header"
import { TreeLayout } from "@/components/tree/tree-layout"
import { D3OrgChartFlow, type D3OrgChartRef } from "@/components/tree/d3-org-chart-flow"
import { useFamily } from "@/context/family-context"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown } from "lucide-react"
import { useState, useRef, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
type ViewMode = "traditional" | "d3-org-chart"
export default function TreePage() {
const { treeData } = useFamily()
const router = useRouter()
const searchParams = useSearchParams()
const [viewMode, setViewMode] = useState<ViewMode>("traditional")
// 从 URL 参数读取视图模式
useEffect(() => {
const view = searchParams.get('view')
if (view === 'd3-org-chart' || view === 'traditional') {
setViewMode(view)
}
}, [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 containerRef = useRef<HTMLDivElement>(null)
const d3ChartRef = useRef<D3OrgChartRef>(null)
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 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)
}
}
// 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 text-muted-foreground"></div>
</div>
)
}
return (
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
{/* 视图切换按钮 */}
<div className="absolute top-20 right-4 z-30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 shadow-lg">
<LayoutGrid className="h-4 w-4" />
{viewMode === 'traditional' ? '传统视图' : 'D3家谱图'}
<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>
<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">
{/* Toolbar */}
<div className="absolute top-4 left-4 z-20 flex flex-col gap-2 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.min(s + 0.1, 2))}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.max(s - 0.1, 0.1))}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setPosition({ x: 0, y: 0 })}>
<Move className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon" onClick={handleExportImage} title="导出为图片">
<Download className="h-4 w-4" />
</Button>
</div>
{/* Tree Canvas */}
<div
ref={containerRef}
className="w-full h-full overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/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} />
</div>
</div>
</div>
) : (
<div className="flex-1 relative">
{/* 统一的控制按钮 */}
<div className="absolute top-4 left-4 z-20 flex flex-col gap-2 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.expandAll()} title="展开全部">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.collapseAll()} title="折叠全部">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.fitView()} title="适应视图">
<Move className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.exportPNG()} title="导出PNG">
<Download className="h-4 w-4" />
</Button>
</div>
<div className="w-full h-full p-4">
<D3OrgChartFlow
ref={d3ChartRef}
members={treeData.members}
rootId={treeData.rootId}
onMemberClick={(id: string) => router.push(`/members/${id}`)}
/>
</div>
</div>
)}
</div>
)
}