633 lines
24 KiB
TypeScript
633 lines
24 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 } 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>
|
||
)
|
||
}
|