Files
chinese-family-tree-2/app/tree/page.tsx
T
freedakgmail b25f4bea02 0.0.9.0
2025-11-24 16:07:24 +08:00

579 lines
22 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 { 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, User } 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"
type ViewMode = "traditional" | "d3-org-chart"
export default function TreePage() {
const { treeData } = useFamily()
const { calculateRelationship } = useRelationship()
const { data: session, status } = useSession()
const router = useRouter()
const searchParams = useSearchParams()
const [viewMode, setViewMode] = useState<ViewMode>("traditional")
// 关系查询模式
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])
// 从 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 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')
// 更新节点样式的辅助函数
const updateNodeStyle = (nodeId: string, isSelected: boolean) => {
// 从DOM中查找D3图表容器
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) {
if (isSelected) {
// 添加勾选图标
if (!nameDiv.querySelector('svg')) {
const iconHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="margin-left: 4px"><polyline points="20 6 9 17 4 12"/></svg>`
nameDiv.style.display = 'flex'
nameDiv.style.alignItems = 'center'
nameDiv.insertAdjacentHTML('beforeend', iconHTML)
}
} else {
// 移除勾选图标
const checkIcon = nameDiv.querySelector('svg')
if (checkIcon) {
checkIcon.remove()
}
}
}
}
})
}
// 直接处理选择逻辑
if (selectedMembersRef.current.length === 0) {
updateNodeStyle(id, true)
setSelectedMembers([id])
} else if (selectedMembersRef.current.length === 1) {
const [firstId] = selectedMembersRef.current
if (firstId === id) {
updateNodeStyle(id, false)
setSelectedMembers([])
return
}
updateNodeStyle(id, true)
const result = calculateRelationship(firstId, id)
const firstMember = treeData.members[firstId]
const secondMember = treeData.members[id]
setRelationResult({
from: firstMember,
to: secondMember,
relationship: result
})
setShowRelationDialog(true)
setSelectedMembers([])
setRelationMode(false)
// 选中样式会在对话框关闭时清除
}
} 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)
}
}
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
// 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-20 right-4 z-30 flex gap-2">
{/* 关系查询按钮 */}
<Button
variant={relationMode ? "default" : "outline"}
className="gap-2 shadow-lg"
onClick={toggleRelationMode}
>
<Users className="h-4 w-4" />
{relationMode ? (
selectedMembers.length === 0 ? '选择第一个成员' : '选择第二个成员'
) : '关系查询'}
</Button>
<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}
relationMode={relationMode}
selectedMembers={selectedMembers}
onMemberClick={handleMemberClick}
/>
</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}
relationMode={relationMode}
selectedMembers={selectedMembers}
onMemberClick={handleD3MemberClick}
/>
</div>
</div>
)}
{/* 关系查询结果对话框 */}
<Dialog open={showRelationDialog} onOpenChange={handleDialogClose}>
<DialogContent className="max-w-2xl" aria-describedby="relation-description">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{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>
)
}