This commit is contained in:
freedakgmail
2025-12-10 10:19:29 +08:00
parent 09809c0e49
commit e9b1641509
144 changed files with 1736 additions and 453 deletions
+167 -4
View File
@@ -16,13 +16,14 @@ import {
} 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 { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users, Network, Loader2, Maximize2, FileText } 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"
import { exportFamilyHTML } from "@/lib/export-pdf"
// 动态导入 D3 图表组件(减少初始加载体积)
const D3OrgChartFlow = dynamic(
@@ -92,9 +93,138 @@ export default function TreePage() {
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)
// 传统族谱初始定位到始祖并自适应缩放
useEffect(() => {
if (viewMode !== 'traditional' || hasInitialPositioned || !treeData.rootId) return
// 找到始祖成员:优先使用标记为 isFounder 的成员,否则使用 rootId 对应的成员
const founderMember = Object.values(treeData.members).find(m => m.isFounder === true)
|| treeData.members[treeData.rootId]
if (!founderMember) return
// 延迟执行,等待 DOM 渲染完成
const timer = setTimeout(() => {
if (!containerRef.current) return
// 查找树内容容器(包含 TreeLayout 的 div
const treeContent = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement
if (!treeContent) return
// 查找始祖节点的 DOM 元素
const founderNode = containerRef.current.querySelector(`[data-member-id="${founderMember.id}"]`) as HTMLElement
if (!founderNode) return
const containerRect = containerRef.current.getBoundingClientRect()
const treeRect = treeContent.getBoundingClientRect()
// 计算树的实际尺寸(当前缩放为1)
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) // 最大不超过1
const newScale = Math.max(fitScale, 0.2) // 最小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
// 计算需要的 x 偏移量使始祖居中
const offsetX = containerCenterX - nodeCenterX
setPosition({ x: offsetX, y: 0 })
setHasInitialPositioned(true)
}, 100)
}, 500)
return () => clearTimeout(timer)
}, [viewMode, treeData.rootId, treeData.members, hasInitialPositioned])
// 切换视图模式时重置初始定位状态和缩放位置
useEffect(() => {
setHasInitialPositioned(false)
setScale(1)
setPosition({ x: 0, y: 0 })
}, [viewMode])
// 传统族谱自适应视图函数
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
// 查找树内容容器
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 / scale
const treeHeight = treeRect.height / scale
// 计算适合容器的缩放比例
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)
// 先重置位置到0,再设置缩放
setPosition({ x: 0, y: 0 })
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 })
}, 150)
}, [treeData.rootId, treeData.members, scale])
const handleMouseDown = (e: React.MouseEvent) => {
// 如果点击的是节点卡片,不启动拖拽
const target = e.target as HTMLElement
@@ -406,6 +536,27 @@ export default function TreePage() {
}
}
// 导出 PDF(按世代导出成员信息)
const handleExportPDF = useCallback(async () => {
if (!treeData.members || Object.keys(treeData.members).length === 0) {
alert('没有成员数据可导出')
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)
alert('导出失败,请重试')
}
}, [treeData.members, currentTree?.name])
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
@@ -495,13 +646,19 @@ export default function TreePage() {
<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 })}>
<Button variant="outline" size="icon" onClick={handleTraditionalFitView} title="自适应视图">
<Move className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setExpandAll(true)} title="完全展开">
<Maximize2 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>
<Button variant="outline" size="icon" onClick={handleExportPDF} title="导出族谱PDF">
<FileText className="h-4 w-4" />
</Button>
</div>
{/* Tree Canvas */}
@@ -527,6 +684,9 @@ export default function TreePage() {
relationMode={relationMode}
selectedMembers={selectedMembers}
onMemberClick={handleMemberClick}
expandAll={expandAll}
onExpandAllChange={setExpandAll}
scale={scale}
/>
</div>
</div>
@@ -535,8 +695,8 @@ export default function TreePage() {
<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 variant="outline" size="icon" onClick={() => d3ChartRef.current?.expandAll()} title="完全展开">
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.collapseAll()} title="折叠全部">
<ZoomOut className="h-4 w-4" />
@@ -548,6 +708,9 @@ export default function TreePage() {
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.exportPNG()} title="导出PNG">
<Download className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleExportPDF} title="导出族谱PDF">
<FileText className="h-4 w-4" />
</Button>
</div>
<div className="w-full h-full">