This commit is contained in:
freedakgmail
2025-11-23 14:46:53 +08:00
parent 2b4bc92f1b
commit d797f1abb9
98 changed files with 187824 additions and 161 deletions
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { RelationshipCalculator } from '@/lib/relationship-calculator'
/**
* 计算两个成员之间的关系
* GET /api/trees/[treeId]/relationship?from=xxx&to=yyy
*/
export async function GET(
request: NextRequest,
{ params }: { params: { treeId: string } }
) {
try {
const { searchParams } = new URL(request.url)
const fromId = searchParams.get('from')
const toId = searchParams.get('to')
if (!fromId || !toId) {
return NextResponse.json(
{ error: '缺少参数:from 和 to' },
{ status: 400 }
)
}
// 获取家族树的所有成员
const members = await prisma.familyMember.findMany({
where: { treeId: params.treeId }
})
if (members.length === 0) {
return NextResponse.json(
{ error: '家族树不存在或没有成员' },
{ status: 404 }
)
}
// 计算关系
const calculator = new RelationshipCalculator(members as any)
const result = calculator.getRelationshipDetail(fromId, toId)
// 获取成员信息
const fromMember = members.find(m => m.id === fromId)
const toMember = members.find(m => m.id === toId)
return NextResponse.json({
from: {
id: fromMember?.id,
name: fromMember?.fullName
},
to: {
id: toMember?.id,
name: toMember?.fullName
},
relationship: result.term,
path: result.path,
description: result.description,
success: result.success
})
} catch (error) {
console.error('计算关系失败:', error)
return NextResponse.json(
{ error: '计算关系失败' },
{ status: 500 }
)
}
}
+245
View File
@@ -0,0 +1,245 @@
"use client"
import { useState } from 'react'
import { SiteHeader } from '@/components/site-header'
import { useFamily } from '@/context/family-context'
import { useRelationship } from '@/hooks/use-relationship'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
export default function RelationshipTestPage() {
const { treeData } = useFamily()
const { calculateRelationship, getDirectRelatives, getSiblings } = useRelationship()
const [fromId, setFromId] = useState<string>('')
const [toId, setToId] = useState<string>('')
const [result, setResult] = useState<any>(null)
const members = Object.values(treeData.members)
const handleCalculate = () => {
if (!fromId || !toId) return
const relationship = calculateRelationship(fromId, toId)
const fromMember = treeData.members[fromId]
const toMember = treeData.members[toId]
setResult({
from: fromMember,
to: toMember,
relationship
})
}
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<main className="flex-1 container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* 标题 */}
<div>
<h1 className="text-3xl font-bold font-serif mb-2"></h1>
<p className="text-muted-foreground">
</p>
</div>
{/* 选择成员 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">A</label>
<Select value={fromId} onValueChange={setFromId}>
<SelectTrigger>
<SelectValue placeholder="选择成员A" />
</SelectTrigger>
<SelectContent>
{members.map(member => (
<SelectItem key={member.id} value={member.id}>
{member.fullName} ({member.generation})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">B</label>
<Select value={toId} onValueChange={setToId}>
<SelectTrigger>
<SelectValue placeholder="选择成员B" />
</SelectTrigger>
<SelectContent>
{members.map(member => (
<SelectItem key={member.id} value={member.id}>
{member.fullName} ({member.generation})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleCalculate}
disabled={!fromId || !toId}
className="w-full"
>
</Button>
</CardContent>
</Card>
{/* 计算结果 */}
{result && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent 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">
{result.from.fullName}
</div>
<div className="text-sm text-muted-foreground">
{result.from.generation}
</div>
</div>
<div className="flex flex-col items-center gap-2">
<Badge variant="default" className="text-lg px-4 py-2">
{result.relationship.term}
</Badge>
{result.relationship.path && (
<span className="text-xs text-muted-foreground">
: {result.relationship.path}
</span>
)}
</div>
<div className="text-center">
<div className="text-2xl font-bold font-serif mb-1">
{result.to.fullName}
</div>
<div className="text-sm text-muted-foreground">
{result.to.generation}
</div>
</div>
</div>
{result.relationship.description && (
<div 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>{result.from.fullName}</strong> <strong>{result.to.fullName}</strong> <strong className="text-primary">{result.relationship.term}</strong>
</div>
</div>
)}
{result.relationship.success === false && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<div className="text-sm text-yellow-800 dark:text-yellow-200">
{result.relationship.error || '无法计算关系'}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* 快速查询示例 */}
{fromId && (
<Card>
<CardHeader>
<CardTitle>
{treeData.members[fromId]?.fullName}
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const relatives = getDirectRelatives(fromId)
if (!relatives) return null
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{relatives.father && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{relatives.father.fullName}</div>
</div>
)}
{relatives.mother && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{relatives.mother.fullName}</div>
</div>
)}
{relatives.spouses.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
{relatives.spouses.map((spouse: any) => (
<div key={spouse.id} className="font-medium">
{spouse.fullName}
</div>
))}
</div>
)}
{relatives.children.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1">
({relatives.children.length})
</div>
<div className="space-y-1">
{relatives.children.slice(0, 3).map((child: any) => (
<div key={child.id} className="font-medium text-sm">
{child.fullName}
</div>
))}
{relatives.children.length > 3 && (
<div className="text-xs text-muted-foreground">
{relatives.children.length - 3} ...
</div>
)}
</div>
</div>
)}
</div>
)
})()}
{(() => {
const siblings = getSiblings(fromId)
if (siblings.length === 0) return null
return (
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2">
({siblings.length})
</div>
<div className="flex flex-wrap gap-2">
{siblings.map((sibling: any) => (
<Badge key={sibling.id} variant="outline">
{sibling.fullName}
</Badge>
))}
</div>
</div>
)
})()}
</CardContent>
</Card>
)}
</div>
</main>
</div>
)
}
+242 -6
View File
@@ -6,6 +6,7 @@ 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,
@@ -13,18 +14,36 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown } from "lucide-react"
import { useState, useRef, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users } from "lucide-react"
import { useState, useRef, useEffect, useCallback } from "react"
import { useRouter, useSearchParams } from "next/navigation"
type ViewMode = "traditional" | "d3-org-chart"
export default function TreePage() {
const { treeData } = useFamily()
const { calculateRelationship } = useRelationship()
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])
// 从 URL 参数读取视图模式
useEffect(() => {
const view = searchParams.get('view')
@@ -70,6 +89,146 @@ export default function TreePage() {
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')
router.push(`/members/${id}`)
}
}, [router, calculateRelationship, treeData.members])
const handleExportImage = async () => {
if (!containerRef.current) return
@@ -195,8 +354,20 @@ export default function TreePage() {
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
{/* 视图切换按钮 */}
<div className="absolute top-20 right-4 z-30">
{/* 视图切换和关系查询按钮 */}
<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">
@@ -272,7 +443,12 @@ export default function TreePage() {
}}
className="absolute min-w-full min-h-full flex justify-center pt-20 pb-20"
>
<TreeLayout rootId={treeData.rootId} />
<TreeLayout
rootId={treeData.rootId}
relationMode={relationMode}
selectedMembers={selectedMembers}
onMemberClick={handleMemberClick}
/>
</div>
</div>
</div>
@@ -300,11 +476,71 @@ export default function TreePage() {
ref={d3ChartRef}
members={treeData.members}
rootId={treeData.rootId}
onMemberClick={(id: string) => router.push(`/members/${id}`)}
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">
{relationResult.from.fullName}
</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">
{relationResult.to.fullName}
</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>{relationResult.from.fullName}</strong> <strong>{relationResult.to.fullName}</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>
<div className="text-xs text-muted-foreground break-all">
{relationResult.relationship.path}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}