Files
chinese-family-tree-2/app/tree/page.tsx
T
freedakgmail 09809c0e49 0.6.0.1
2025-12-09 18:52:00 +08:00

633 lines
24 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 } 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"
// 动态导入 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 } = 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])
// 页面加载时刷新数据
useEffect(() => {
if (currentTree?.id) {
refreshData()
}
}, [currentTree?.id, refreshData])
// 从 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 handleWheel = (e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
setScale((s) => Math.min(Math.max(s + delta, 0.1), 2))
}
// 处理成员选择(关系查询模式)
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#2563eb" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10" fill="none" stroke="white" stroke-width="2.5"/></svg>`
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)
}
}
// 未登录时不渲染内容(已在 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 min-h-0">
{/* 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}
onWheel={handleWheel}
>
<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 min-h-0">
{/* 统一的控制按钮 */}
<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">
<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>
)
}