Files
freedakgmail 5a604e89f9 0.8.3.0
2025-12-21 22:42:39 +08:00

1355 lines
52 KiB
TypeScript
Raw Permalink 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 { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo, useCallback } from "react"
import * as d3 from "d3"
// @ts-ignore - d3-org-chart没有TypeScript类型定义
import { OrgChart } from "d3-org-chart"
import { Download, FileText, UserPlus, Users, Baby } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import jsPDF from "jspdf"
import { useRouter } from "next/navigation"
import { useFamily } from "@/context/family-context"
import { GenerationWarningDialog } from "@/components/tree/generation-warning-dialog"
import { shouldShowGenerationWarning } from "@/lib/generation-utils"
// 全局类型声明
declare global {
interface Window {
d3ChartContextMenu?: (event: MouseEvent, memberId: string) => void
d3ChartSpouseClick?: (event: Event, spouseId: string) => void
}
}
interface D3OrgChartFlowProps {
members: Record<string, FamilyMember>
rootId: string
onMemberClick?: (memberId: string) => void
onExport?: () => void
relationMode?: boolean
selectedMembers?: string[]
}
export interface D3OrgChartRef {
exportPNG: () => void
exportPDF: () => void
expandAll: () => void
collapseAll: () => void
fitView: () => void
}
interface SpouseData {
id: string
name: string
avatarUrl: string | null | undefined
gender: string
birthYear: string
deathYear: string
ageText: string
}
interface ChartNode {
id: string
parentId: string | null
name: string
generation: string
birthYear: string
deathYear: string
birthPlace: string
gender: string
avatarUrl: string | null | undefined
spouseName: string
spouseId: string | null
spouseAvatarUrl?: string | null
spouseGender?: string | null
spouseDeathYear?: string | null
spouseBirthYear?: string | null
spouseAgeText?: string
// 多配偶支持
spouses: SpouseData[]
spouseCount: number
hasChildren: boolean
ageText: string
isFounder?: boolean
}
// 计算年龄
function calculateAge(birthDate?: string, deathDate?: string): number | null {
if (!birthDate) return null
const birth = new Date(birthDate)
const end = deathDate ? new Date(deathDate) : new Date()
let age = end.getFullYear() - birth.getFullYear()
const monthDiff = end.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
age--
}
return age
}
const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
({ members, rootId, onMemberClick, onExport, relationMode = false, selectedMembers = [] }, ref) => {
console.log('D3OrgChartFlow render, selectedMembers:', selectedMembers, 'relationMode:', relationMode)
const chartRef = useRef<HTMLDivElement>(null)
const chartInstanceRef = useRef<any>(null)
const [isReady, setIsReady] = useState(false)
const [avatarCache, setAvatarCache] = useState<Map<string, string>>(new Map())
const [contextMenuNode, setContextMenuNode] = useState<FamilyMember | null>(null)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const router = useRouter()
const { currentTree } = useFamily()
// 代数重新计算警告对话框状态
const [showWarningDialog, setShowWarningDialog] = useState(false)
const [pendingAddMember, setPendingAddMember] = useState<{ type: 'father' | 'mother', member: FamilyMember } | null>(null)
// 构建新增成员的 URL
const buildAddUrl = (type: 'father' | 'mother' | 'spouse' | 'child', member: FamilyMember) => {
const params = new URLSearchParams()
if (currentTree?.id) {
params.set('treeId', currentTree.id)
}
switch (type) {
case 'father':
params.set('childId', member.id)
params.set('gender', 'MALE')
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
// 如果已有母亲,设为配偶
if (member.motherId) {
params.set('spouseId', member.motherId)
}
break
case 'mother':
params.set('childId', member.id)
params.set('gender', 'FEMALE')
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
// 如果已有父亲,设为配偶
if (member.fatherId) {
params.set('spouseId', member.fatherId)
}
break
case 'spouse':
params.set('spouseId', member.id)
params.set('generation', member.generation.toString())
// 配偶性别与当前成员相反
params.set('gender', member.gender === 'MALE' ? 'FEMALE' : 'MALE')
// 如果当前成员有子女,将他们也作为新配偶的子女
if (member.childrenIds && member.childrenIds.length > 0) {
// 传递所有子女ID
member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
break
case 'child':
// 根据当前成员性别设置父/母
if (member.gender === 'MALE') {
params.set('fatherId', member.id)
// 如果父亲有配偶,设置配偶为母亲
if (member.spouseIds && member.spouseIds.length > 0) {
params.set('motherId', member.spouseIds[0])
}
} else {
params.set('motherId', member.id)
// 如果母亲有配偶,设置配偶为父亲
if (member.spouseIds && member.spouseIds.length > 0) {
params.set('fatherId', member.spouseIds[0])
}
}
params.set('generation', (member.generation + 1).toString())
break
}
return `/members/new?${params.toString()}`
}
const handleAddMember = (type: 'father' | 'mother' | 'spouse' | 'child', member: FamilyMember) => {
// 检查是否需要显示代数重新计算警告
if ((type === 'father' || type === 'mother') && shouldShowGenerationWarning(member, type)) {
setPendingAddMember({ type, member })
setShowWarningDialog(true)
return
}
const url = buildAddUrl(type, member)
console.log('D3 handleAddMember called:', type, url)
// 使用 setTimeout 延迟跳转,确保菜单关闭后再执行
setTimeout(() => {
console.log('Navigating to:', url)
window.location.href = url
}, 100)
}
// 确认添加父母后的处理
const handleConfirmAddParent = () => {
if (pendingAddMember) {
const url = buildAddUrl(pendingAddMember.type, pendingAddMember.member)
console.log('Confirmed add parent, navigating to:', url)
setTimeout(() => {
window.location.href = url
}, 100)
setPendingAddMember(null)
}
}
// 判断可以添加的关系
const canAddFather = (member: FamilyMember) => !member.fatherId
const canAddMother = (member: FamilyMember) => !member.motherId
const canAddSpouse = (member: FamilyMember) => true // 配偶总是可以添加
const canAddChild = (member: FamilyMember) => true // 子女总是可以添加
// 转换数据格式
const convertToChartData = (): ChartNode[] => {
const chartNodes: ChartNode[] = []
// 首先找到根节点(没有父节点的节点)
const rootMember = members[rootId]
if (!rootMember) return []
// 使用BFS遍历,确保只包含从rootId开始的树
const visited = new Set<string>()
const queue: string[] = [rootId]
while (queue.length > 0) {
const currentId = queue.shift()!
if (visited.has(currentId)) continue
visited.add(currentId)
const member = members[currentId]
if (!member) continue
// 获取所有配偶信息,确保配偶存在于members中
const spouses = member.spouseIds
?.map(id => members[id])
.filter(Boolean) || []
// 确定父节点ID - 只有根节点的parentId为null
// 重要:parentId 必须指向已经在 visited 集合中的节点,否则 d3-org-chart 会报 missing 错误
let parentId: string | null = null
if (currentId !== rootId) {
// 根据姓氏判断主线,优先选择同姓的父母
// 同时确保父母已经被访问过(在 visited 中)
const father = member.fatherId && visited.has(member.fatherId) ? members[member.fatherId] : null
const mother = member.motherId && visited.has(member.motherId) ? members[member.motherId] : null
// 判断父母中谁是家族主线(同姓)
const isFatherMainLine = father && member.surname === father.surname
const isMotherMainLine = mother && member.surname === mother.surname
if (isFatherMainLine) {
parentId = member.fatherId || null
} else if (isMotherMainLine) {
parentId = member.motherId || null
} else if (father) {
// 如果都不同姓,优先选择父亲
parentId = member.fatherId || null
} else if (mother) {
parentId = member.motherId || null
}
}
// 计算年龄文本
const age = calculateAge(member.birthDate, member.deathDate)
const ageText = age !== null
? member.deathDate
? `享年${age}`
: `${age}`
: ''
// 处理多配偶数据
const spousesData = spouses.map(sp => ({
id: sp.id,
name: sp.fullName,
avatarUrl: sp.avatarUrl,
gender: sp.gender === 'MALE' ? '男' : '女',
birthYear: sp.birthDate ? new Date(sp.birthDate).getFullYear().toString() : '',
deathYear: sp.deathDate ? new Date(sp.deathDate).getFullYear().toString() : '',
ageText: (() => {
const age = calculateAge(sp.birthDate, sp.deathDate)
return age !== null ? (sp.deathDate ? `享年${age}` : `${age}`) : ''
})()
}))
chartNodes.push({
id: member.id,
parentId: parentId,
name: member.fullName,
generation: `${member.generation}`,
birthYear: member.birthDate ? new Date(member.birthDate).getFullYear().toString() : '',
deathYear: member.deathDate ? new Date(member.deathDate).getFullYear().toString() : '',
birthPlace: member.birthPlace || '',
gender: member.gender === 'MALE' ? '男' : '女',
avatarUrl: member.avatarUrl,
// 保留旧字段兼容
spouseName: spouses.length > 0 ? spouses.map(s => s.fullName).join(', ') : '',
spouseId: spouses.length > 0 ? spouses[0].id : null,
spouseAvatarUrl: spouses.length > 0 ? spouses[0].avatarUrl : null,
spouseGender: spouses.length > 0 ? (spouses[0].gender === 'MALE' ? '男' : '女') : null,
spouseDeathYear: spouses.length > 0 ? (spouses[0].deathDate ? new Date(spouses[0].deathDate).getFullYear().toString() : '') : null,
spouseBirthYear: spouses.length > 0 ? (spouses[0].birthDate ? new Date(spouses[0].birthDate).getFullYear().toString() : '') : null,
spouseAgeText: spouses.length > 0 ? (() => {
const sp = spouses[0]
const age = calculateAge(sp.birthDate, sp.deathDate)
return age !== null ? (sp.deathDate ? `享年${age}` : `${age}`) : ''
})() : '',
// 新增多配偶数组
spouses: spousesData,
spouseCount: spouses.length,
hasChildren: Object.values(members).some(m => m.fatherId === member.id || m.motherId === member.id),
ageText: ageText,
isFounder: member.id === rootId // 使用 rootId 判断始祖
})
// 添加子节点到队列
Object.values(members).forEach(m => {
if ((m.fatherId === currentId || m.motherId === currentId) && !visited.has(m.id)) {
queue.push(m.id)
}
})
}
return chartNodes
}
// 构建头像缓存(URL 直接使用)
useEffect(() => {
const cache = new Map<string, string>()
for (const member of Object.values(members)) {
if (member.avatarUrl) {
cache.set(member.avatarUrl, member.avatarUrl)
}
}
setAvatarCache(cache)
}, [members])
useEffect(() => {
if (!chartRef.current || !rootId) return
// 清理旧的图表
if (chartRef.current) {
chartRef.current.innerHTML = ''
}
// iOS/iPadOS 检测(包括 Safari、Chrome 等所有浏览器)
// iPad 上所有浏览器都使用 WebKit 引擎
const container = chartRef.current
const isTouchDevice = navigator.maxTouchPoints > 1
const isWebKit = /AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
const isIOSChrome = /CriOS/.test(navigator.userAgent)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && isTouchDevice) ||
isIOSChrome
console.log('Device detection:', { isIOS, isTouchDevice, isWebKit, isIOSChrome, userAgent: navigator.userAgent })
// 对于 iOS,设置明确的像素高度
if (isIOS) {
const headerHeight = 120
const calculatedHeight = window.innerHeight - headerHeight
container.style.height = `${calculatedHeight}px`
}
const data = convertToChartData()
// 获取容器实际尺寸 - iOS Safari 可能返回 0,所以使用 window 尺寸作为后备
const containerRect = chartRef.current.getBoundingClientRect()
let containerWidth = containerRect.width
let containerHeight = containerRect.height
// 如果尺寸无效,使用 window 尺寸
if (!containerWidth || containerWidth < 100) {
containerWidth = window.innerWidth
}
if (!containerHeight || containerHeight < 100) {
containerHeight = window.innerHeight - 120
}
console.log('D3 Chart container size:', { containerWidth, containerHeight, isIOS })
// 初始化图表
const chart = new OrgChart()
.container(chartRef.current)
.data(data)
.svgWidth(containerWidth)
.svgHeight(containerHeight)
.scaleExtent([0.1, 2]) // 缩放范围
.initialZoom(0.5) // 初始缩放级别
.nodeWidth((d: any) => {
const spouseCount = d.data.spouseCount || 0
return spouseCount === 0 ? 170 : spouseCount === 1 ? 300 : 170 + spouseCount * 110
}) // 根据配偶数量动态调整宽度
.nodeHeight(() => 175)
.childrenMargin(() => 50)
.compactMarginBetween(() => 20) // 减少间距
.compactMarginPair(() => 20)
.neighbourMargin(() => 20)
.siblingsMargin(() => 20)
.linkUpdate(function (d: any, i: any, arr: any) {
// 现代风格连线 - 使用柔和的灰蓝色
d3.select(arr[i])
.attr('stroke', '#94a3b8')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '')
.style('opacity', 0.6)
})
.buttonContent(({ node }: any) => {
return `<div style="
color: #64748b;
border-radius: 20px;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
margin: auto;
background: white;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
" onmouseover="this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'" onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.08)'">
<span style="font-size: 10px;">${node.children ? '▲' : '▼'}</span>
<span>${node.data._directSubordinates || 0}</span>
</div>`
})
.nodeContent((d: any) => {
const node = d.data
const isMale = node.gender === '男'
const isDead = !!node.deathYear
const hasSpouse = !!node.spouseId
const isFounder = node.isFounder === true
// 现代简约风格配色
const topBarGradient = isFounder
? 'linear-gradient(to right, #f59e0b, #ea580c, #f59e0b)'
: isDead
? 'linear-gradient(to right, #78716c, #57534e, #78716c)'
: isMale
? 'linear-gradient(to right, #38bdf8, #3b82f6, #38bdf8)'
: 'linear-gradient(to right, #fb7185, #ec4899, #fb7185)'
const bgGradient = isFounder
? 'linear-gradient(135deg, #fffbeb, #fef3c7, #fef9c3)'
: isDead
? 'linear-gradient(135deg, #f5f5f4, #e7e5e4, #f5f5f4)'
: isMale
? 'linear-gradient(135deg, #f0f9ff, #e0f2fe, #ecfeff)'
: 'linear-gradient(135deg, #fff1f2, #fce7f3, #fdf2f8)'
const avatarBorder = isFounder
? '#f59e0b'
: isDead ? '#a8a29e' : isMale ? '#38bdf8' : '#fb7185'
const avatarGlow = isFounder
? 'rgba(245, 158, 11, 0.4)'
: isDead ? 'rgba(168, 162, 158, 0.3)' : isMale ? 'rgba(56, 189, 248, 0.4)' : 'rgba(251, 113, 133, 0.4)'
const textColor = '#000000' // 纯黑色文字
const badgeGradient = isFounder
? 'linear-gradient(to right, #f59e0b, #ea580c)'
: isDead
? 'linear-gradient(to right, #78716c, #57534e)'
: isMale
? 'linear-gradient(to right, #38bdf8, #3b82f6)'
: 'linear-gradient(to right, #fb7185, #ec4899)'
// 获取头像URL
const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null
// 检查是否被选中
const isSelected = selectedMembers.includes(node.id)
// 多配偶数据
const spousesArray = node.spouses || []
const spouseCount = spousesArray.length
// 动态宽度:根据配偶数量调整
const width = spouseCount === 0 ? 150 : spouseCount === 1 ? 280 : 150 + spouseCount * 100
return `
<div
data-member-id="${node.id}"
style="
position: relative;
width: ${width}px;
height: 160px;
box-sizing: border-box;
background: ${bgGradient};
border-radius: 16px;
padding: 16px 12px 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
cursor: pointer;
transition: all 0.3s ease;
-webkit-transform: translateZ(0);
transform: translateZ(0);
"
onmouseover="this.style.boxShadow='0 12px 40px rgba(0,0,0,0.15)'; this.style.transform='translateY(-4px) scale(1.02)'"
onmouseout="this.style.boxShadow='0 4px 20px rgba(0,0,0,0.08)'; this.style.transform='translateZ(0)'"
>
<!-- 顶部装饰条 -->
<div style="
position: absolute;
top: 0;
left: 16px;
right: 16px;
height: 4px;
background: ${topBarGradient};
border-radius: 0 0 4px 4px;
"></div>
<!-- 世代标签 - 胶囊风格 -->
<div style="
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
background: ${badgeGradient};
color: white;
font-size: 10px;
font-weight: 500;
padding: 3px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 10;
letter-spacing: 1px;
">
${node.generation}
</div>
<!-- 选中标记 -->
${isSelected ? `
<div style="position: absolute; top: 8px; left: 8px; z-index: 20;">
<div style="width: 20px; height: 20px; border-radius: 50%; background: #3b82f6; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(59,130,246,0.4);">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
</div>
` : ''}
<div style="display: flex; justify-content: center; gap: 16px; margin-top: 8px;">
<!-- 主成员 -->
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; flex: 1;">
<!-- 头像带光晕 -->
<div style="position: relative;">
<div style="
position: absolute;
inset: -2px;
border-radius: 50%;
background: ${avatarGlow};
filter: blur(8px);
"></div>
<div style="
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 3px solid ${avatarBorder};
box-shadow: 0 4px 12px rgba(0,0,0,0.1), inset 0 0 0 2px white;
">
${avatarUrl
? `<img src="${avatarUrl}" alt="${node.name}" style="width: 100%; height: 100%; object-fit: cover;" />`
: node.gender === '女'
? `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="${isDead ? '#a8a29e' : '#ec4899'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/><circle cx="12" cy="12" r="10"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="${isDead ? '#a8a29e' : '#3b82f6'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>`
}
</div>
<!-- 状态指示器 -->
<div style="
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${isDead ? '#a8a29e' : '#22c55e'};
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
"></div>
</div>
<!-- 姓名 -->
<div style="
font-size: 15px;
font-weight: 600;
color: ${textColor};
letter-spacing: 0.5px;
white-space: nowrap;
font-family: serif;
">
${node.name}
</div>
<!-- 生卒信息 -->
<div style="text-align: center;">
${node.birthYear ? `
<div style="
font-size: 10px;
color: #000000;
font-family: serif;
background: ${isDead ? 'rgba(168,162,158,0.3)' : 'rgba(107,114,128,0.2)'};
padding: 2px 8px;
border-radius: 10px;
display: inline-block;
font-weight: 500;
">
${node.birthYear}${node.deathYear ? `${node.deathYear}` : ' —'}
</div>
${node.ageText ? `<div style="font-size: 10px; color: #000000; margin-top: 2px; font-weight: 500; font-family: serif;">${node.ageText}</div>` : ''}
` : ''}
</div>
</div>
<!-- 配偶们 -->
${spousesArray.map((sp: any, idx: number) => {
const spIsMale = sp.gender === '男'
const spIsDead = !!sp.deathYear
const spAvatarBorder = spIsDead ? '#a8a29e' : spIsMale ? '#38bdf8' : '#fb7185'
const spAvatarGlow = spIsDead ? 'rgba(168, 162, 158, 0.3)' : spIsMale ? 'rgba(56, 189, 248, 0.4)' : 'rgba(251, 113, 133, 0.4)'
const spAvatarUrl = sp.avatarUrl ? avatarCache.get(sp.avatarUrl) : null
return `
<div
class="spouse-area"
data-spouse-id="${sp.id}"
style="display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; position: relative; min-width: 80px;"
onclick="event.stopPropagation(); window.d3ChartSpouseClick && window.d3ChartSpouseClick(event, '${sp.id}')"
>
${idx === 0 ? `
<!-- 连接线 -->
<div style="
position: absolute;
left: -8px;
top: 22px;
width: 16px;
height: 2px;
background: linear-gradient(to right, ${avatarBorder}, ${spAvatarBorder});
border-radius: 1px;
"></div>
` : ''}
<!-- 头像带光晕 -->
<div style="position: relative;">
<div style="
position: absolute;
inset: -2px;
border-radius: 50%;
background: ${spAvatarGlow};
filter: blur(6px);
"></div>
<div style="
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px solid ${spAvatarBorder};
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
">
${spAvatarUrl
? `<img src="${spAvatarUrl}" alt="${sp.name}" style="width: 100%; height: 100%; object-fit: cover;" />`
: spIsMale
? `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${spIsDead ? '#a8a29e' : '#3b82f6'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${spIsDead ? '#a8a29e' : '#ec4899'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/><circle cx="12" cy="12" r="10"/></svg>`
}
</div>
<!-- 状态指示器 -->
<div style="
position: absolute;
bottom: -1px;
right: -1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: ${spIsDead ? '#a8a29e' : '#22c55e'};
border: 2px solid white;
"></div>
</div>
<!-- 姓名 -->
<div style="
font-size: 12px;
font-weight: 600;
color: #000000;
white-space: nowrap;
font-family: serif;
">
${sp.name}
</div>
<!-- 生卒信息 -->
${sp.birthYear ? `
<div style="
font-size: 9px;
color: #000000;
font-family: serif;
background: ${spIsDead ? 'rgba(168,162,158,0.3)' : 'rgba(107,114,128,0.2)'};
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
">
${sp.birthYear}${sp.deathYear ? `-${sp.deathYear}` : '-'}
</div>
${sp.ageText ? `<div style="font-size: 9px; color: #000000; margin-top: 2px; font-weight: 500; font-family: serif;">${sp.ageText}</div>` : ''}
` : ''}
</div>
`
}).join('')}
</div>
</div>
`
})
.onNodeClick((d: any) => {
if (onMemberClick && d) {
// d3-org-chart传递的是节点数据对象,需要提取id
const nodeId = typeof d === 'string' ? d : (d.data?.id || d.id)
if (nodeId) {
onMemberClick(nodeId)
}
}
})
.render()
.expandAll() // 初始化时展开所有节点
.fit() // 自适应全图显示
// 延迟再次 fit
setTimeout(() => chart.fit(), 100)
setTimeout(() => chart.fit(), 300)
// 所有触摸设备都需要特殊处理 - iOS foreignObject bug 修复
if (isTouchDevice) {
setTimeout(() => {
const w = window.innerWidth
const h = window.innerHeight - 120
if (chartRef.current) {
chartRef.current.style.height = `${h}px`
chartRef.current.style.width = `${w}px`
// iOS foreignObject bug 修复:强制重绘 SVG
const svg = chartRef.current.querySelector('svg')
if (svg) {
// 触发重绘
svg.style.display = 'none'
void (svg as any).getBBox() // 强制回流
svg.style.display = ''
}
}
// 重新设置 SVG 尺寸并渲染
chart.svgWidth(w).svgHeight(h).render().expandAll().fit()
console.log('Touch device delayed render:', { w, h })
}, 500)
// 再次强制重绘
setTimeout(() => {
if (chartRef.current) {
const svg = chartRef.current.querySelector('svg')
if (svg) {
svg.style.transform = 'translateZ(0)'
// 强制所有 foreignObject 重新定位
const foreignObjects = svg.querySelectorAll('foreignObject')
foreignObjects.forEach((fo: Element) => {
(fo as HTMLElement).style.transform = 'translateZ(0)'
})
}
}
chart.fit()
}, 1000)
setTimeout(() => chart.fit(), 1500)
}
// 注册全局配偶点击处理函数
window.d3ChartSpouseClick = (event: Event, spouseId: string) => {
event.stopPropagation()
if (spouseId && onMemberClick) {
onMemberClick(spouseId)
}
}
// 添加右键菜单支持
setTimeout(() => {
const nodes = chartRef.current?.querySelectorAll('[data-member-id]')
console.log('Found nodes for context menu:', nodes?.length)
nodes?.forEach((node) => {
const memberId = (node as HTMLElement).getAttribute('data-member-id')
if (memberId) {
node.addEventListener('contextmenu', (e) => {
e.preventDefault()
const mouseEvent = e as MouseEvent
const member = members[memberId]
if (member && !relationMode) {
console.log('Right click on member:', member.fullName, 'at', mouseEvent.clientX, mouseEvent.clientY)
setContextMenuNode(member)
setContextMenuPosition({ x: mouseEvent.clientX, y: mouseEvent.clientY })
}
})
}
})
}, 200)
chartInstanceRef.current = chart
setIsReady(true)
return () => {
// 清理
if (chartRef.current) {
chartRef.current.innerHTML = ''
}
// 清理全局函数
if (window.d3ChartContextMenu) {
delete window.d3ChartContextMenu
}
if (window.d3ChartSpouseClick) {
delete window.d3ChartSpouseClick
}
}
}, [members, rootId, onMemberClick, avatarCache])
// 监听窗口大小变化,重新适应视图
useEffect(() => {
const handleResize = () => {
if (chartInstanceRef.current) {
// 延迟执行 fit,等待布局稳定
setTimeout(() => {
chartInstanceRef.current?.fit()
}, 100)
}
}
window.addEventListener('resize', handleResize)
// 监听屏幕方向变化(iPad 等设备)
window.addEventListener('orientationchange', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('orientationchange', handleResize)
}
}, [])
// 监听 selectedMembers 变化,通过 DOM 操作更新选中标记
useEffect(() => {
console.log('Selection useEffect triggered, selectedMembers:', selectedMembers, 'isReady:', isReady)
if (!chartRef.current || !isReady) return
// 移除所有现有的选中标记
const existingMarks = chartRef.current.querySelectorAll('.selection-mark')
existingMarks.forEach(mark => mark.remove())
// 为选中的成员添加选中标记(只打钩,不改变边框和背景)
selectedMembers.forEach(memberId => {
console.log('Marking member as selected:', memberId)
// 先尝试查找主成员节点
const nodeElement = chartRef.current?.querySelector(`[data-member-id="${memberId}"]`)
// 再尝试查找配偶区域
const spouseElement = chartRef.current?.querySelector(`[data-spouse-id="${memberId}"]`)
if (nodeElement) {
// 主成员:只添加打钩标记(左边)
const checkMark = document.createElement('div')
checkMark.className = 'selection-mark'
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" stroke-linecap="round" stroke-linejoin="round"><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 if (spouseElement) {
// 配偶:只添加打钩标记
const checkMark = document.createElement('div')
checkMark.className = 'selection-mark'
checkMark.style.cssText = 'position: absolute; top: -5px; right: -5px; 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" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10" fill="none" stroke="white" stroke-width="2.5"/></svg>`
spouseElement.appendChild(checkMark)
}
})
}, [selectedMembers, isReady])
// 导出PNG
const handleExportPNG = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.exportImg({
save: true,
full: true,
onLoad: () => {
onExport?.()
}
})
}
// 导出PDF
const handleExportPDF = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.exportImg({
save: false,
full: true,
onLoad: (base64: string) => {
const pdf = new jsPDF({
orientation: 'landscape',
unit: 'px',
format: 'a4'
})
const img = new Image()
img.src = base64
img.onload = function () {
const imgWidth = img.width
const imgHeight = img.height
const pdfWidth = pdf.internal.pageSize.getWidth()
const pdfHeight = pdf.internal.pageSize.getHeight()
// 计算缩放比例以适应页面
const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight)
const scaledWidth = imgWidth * ratio
const scaledHeight = imgHeight * ratio
// 居中放置
const x = (pdfWidth - scaledWidth) / 2
const y = (pdfHeight - scaledHeight) / 2
pdf.addImage(img, 'PNG', x, y, scaledWidth, scaledHeight)
pdf.save(`家谱图-${new Date().toISOString().split('T')[0]}.pdf`)
onExport?.()
}
}
})
}
// 展开所有节点
const handleExpandAll = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.expandAll()
}
// 折叠所有节点
const handleCollapseAll = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.collapseAll()
}
// 适应视图
const handleFitView = () => {
if (!chartInstanceRef.current) return
chartInstanceRef.current.fit()
}
// Minimap 状态
const minimapRef = useRef<HTMLCanvasElement>(null)
const [minimapExpanded, setMinimapExpanded] = useState(true)
const minimapDataRef = useRef<{
bbox: { x: number; y: number; width: number; height: number }
mmScale: number
offsetX: number
offsetY: number
} | null>(null)
const isDraggingMinimapRef = useRef(false)
// 更新 minimap(使用 Canvas 绘制)
const updateMinimap = useCallback(() => {
if (!minimapRef.current || !chartRef.current) return
const canvas = minimapRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
const mainSvg = chartRef.current.querySelector('svg')
if (!mainSvg) return
const mmW = 180, mmH = 130
canvas.width = mmW
canvas.height = mmH
// 清空画布
ctx.fillStyle = '#fafafa'
ctx.fillRect(0, 0, mmW, mmH)
// 直接从 SVG 中查找所有 foreignObject,不管它们在哪个 g 元素中
const foreignObjects = mainSvg.querySelectorAll('foreignObject')
if (foreignObjects.length === 0) return
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
// 收集节点位置数据 - 需要考虑父元素的 transform
const nodePositions: { x: number; y: number; w: number; h: number }[] = []
foreignObjects.forEach((fo) => {
// 获取 foreignObject 的属性
let x = parseFloat(fo.getAttribute('x') || '0')
let y = parseFloat(fo.getAttribute('y') || '0')
const w = parseFloat(fo.getAttribute('width') || '0')
const h = parseFloat(fo.getAttribute('height') || '0')
// 检查父元素是否有 transformd3-org-chart 的节点通常在有 transform 的 g 元素中)
const parentG = fo.parentElement
if (parentG && parentG.tagName === 'g') {
const parentTransform = parentG.getAttribute('transform')
if (parentTransform) {
const translateMatch = parentTransform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/)
if (translateMatch) {
x += parseFloat(translateMatch[1])
y += parseFloat(translateMatch[2])
}
}
}
if (w > 0 && h > 0) {
nodePositions.push({ x, y, w, h })
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + w)
maxY = Math.max(maxY, y + h)
}
})
if (minX === Infinity || minY === Infinity || nodePositions.length === 0) return
// 添加边距
const padding = 20
const contentX = minX - padding
const contentY = minY - padding
const contentW = (maxX - minX) + padding * 2
const contentH = (maxY - minY) + padding * 2
// 计算缩放比例,确保内容完全显示在 minimap 中
const scaleX = (mmW - 8) / contentW
const scaleY = (mmH - 8) / contentH
const mmScale = Math.min(scaleX, scaleY)
// 计算偏移使内容居中
const offsetX = (mmW - contentW * mmScale) / 2 - contentX * mmScale
const offsetY = (mmH - contentH * mmScale) / 2 - contentY * mmScale
// 保存数据供点击使用(使用原始坐标系)
minimapDataRef.current = {
bbox: { x: contentX, y: contentY, width: contentW, height: contentH },
mmScale,
offsetX,
offsetY
}
// 绘制连线(先画连线,再画节点)
ctx.strokeStyle = '#cbd5e1'
ctx.lineWidth = 1
mainSvg.querySelectorAll('path').forEach((path) => {
const d = path.getAttribute('d')
// 跳过没有 d 属性或者是其他类型的 path
if (!d || d.length < 5) return
const path2D = new Path2D()
const commands = d.match(/[MLHVCSQTAZ][^MLHVCSQTAZ]*/gi) || []
commands.forEach(cmd => {
const type = cmd[0].toUpperCase()
const nums = cmd.slice(1).trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n))
if (type === 'M' && nums.length >= 2) {
path2D.moveTo(nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY)
} else if (type === 'L' && nums.length >= 2) {
path2D.lineTo(nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY)
} else if (type === 'C' && nums.length >= 6) {
path2D.bezierCurveTo(
nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY,
nums[2] * mmScale + offsetX, nums[3] * mmScale + offsetY,
nums[4] * mmScale + offsetX, nums[5] * mmScale + offsetY
)
}
})
ctx.stroke(path2D)
})
// 绘制节点
ctx.fillStyle = '#3b82f6'
nodePositions.forEach(({ x, y, w, h }) => {
const drawX = x * mmScale + offsetX
const drawY = y * mmScale + offsetY
const drawW = Math.max(w * mmScale, 4)
const drawH = Math.max(h * mmScale, 3)
ctx.beginPath()
ctx.roundRect(drawX, drawY, drawW, drawH, 2)
ctx.fill()
})
// 获取当前 transform - d3-org-chart 的 transform 在第一个 g 元素上
const mainG = mainSvg.querySelector('g') as SVGGElement
const transform = mainG?.getAttribute('transform') || ''
const translateMatch = transform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/)
const scaleMatch = transform.match(/scale\(\s*([^)\s]+)\s*\)/)
const tx = translateMatch ? parseFloat(translateMatch[1]) : 0
const ty = translateMatch ? parseFloat(translateMatch[2]) : 0
const k = scaleMatch ? parseFloat(scaleMatch[1]) : 1
// 计算视口在原始坐标系中的位置
const svgRect = mainSvg.getBoundingClientRect()
const viewX = -tx / k
const viewY = -ty / k
const viewW = svgRect.width / k
const viewH = svgRect.height / k
// 绘制视口框
const vpDrawX = viewX * mmScale + offsetX
const vpDrawY = viewY * mmScale + offsetY
const vpDrawW = viewW * mmScale
const vpDrawH = viewH * mmScale
ctx.strokeStyle = '#ef4444'
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'
ctx.beginPath()
ctx.rect(vpDrawX, vpDrawY, vpDrawW, vpDrawH)
ctx.fill()
ctx.stroke()
}, [])
// 定时更新 minimap
useEffect(() => {
if (!minimapExpanded || !isReady) return
const timer = setTimeout(updateMinimap, 500)
const interval = setInterval(updateMinimap, 200)
return () => {
clearTimeout(timer)
clearInterval(interval)
}
}, [minimapExpanded, isReady, updateMinimap])
// 点击/拖拽 minimap 定位到具体位置
const handleMinimapInteraction = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!minimapDataRef.current || !chartRef.current || !chartInstanceRef.current) return
const { mmScale, offsetX, offsetY } = minimapDataRef.current
const canvas = e.currentTarget
const rect = canvas.getBoundingClientRect()
const clickX = e.clientX - rect.left
const clickY = e.clientY - rect.top
// 转换到原始坐标系
const treeX = (clickX - offsetX) / mmScale
const treeY = (clickY - offsetY) / mmScale
// 获取主图
const mainSvg = chartRef.current.querySelector('svg') as SVGSVGElement
if (!mainSvg) return
const mainG = mainSvg.querySelector('g')
if (!mainG) return
const svgRect = mainSvg.getBoundingClientRect()
// 获取当前缩放级别
const transform = mainG.getAttribute('transform') || ''
const scaleMatch = transform.match(/scale\(\s*([^)\s]+)\s*\)/)
const currentK = scaleMatch ? parseFloat(scaleMatch[1]) : 1
// 计算新的 translate,使点击位置移动到视口中心
const newTx = svgRect.width / 2 - treeX * currentK
const newTy = svgRect.height / 2 - treeY * currentK
// 直接修改 g 元素的 transform,不触发 d3-org-chart 的 zoom 事件
// 这样可以避免触发 fit() 等操作
const d3MainG = d3.select(mainG)
if (isDraggingMinimapRef.current) {
d3MainG.attr('transform', `translate(${newTx}, ${newTy}) scale(${currentK})`)
} else {
d3MainG.transition().duration(200)
.attr('transform', `translate(${newTx}, ${newTy}) scale(${currentK})`)
}
// 同步 d3.zoom 的内部状态(重要!避免下次交互时状态不一致)
const d3Svg = d3.select(mainSvg)
const newTransform = d3.zoomIdentity.translate(newTx, newTy).scale(currentK)
// @ts-ignore - 设置 zoom 的 __zoom 属性来同步状态
mainSvg.__zoom = newTransform
}, [])
const handleMinimapMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
isDraggingMinimapRef.current = true
handleMinimapInteraction(e)
}, [handleMinimapInteraction])
const handleMinimapMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDraggingMinimapRef.current) {
handleMinimapInteraction(e)
}
}, [handleMinimapInteraction])
const handleMinimapMouseUp = useCallback(() => {
isDraggingMinimapRef.current = false
}, [])
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
exportPNG: handleExportPNG,
exportPDF: handleExportPDF,
expandAll: handleExpandAll,
collapseAll: handleCollapseAll,
fitView: handleFitView,
}))
return (
<div className="w-full h-full bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat relative overflow-hidden" key={`d3-chart-${rootId}`}>
{/* 图表容器 */}
<div
ref={chartRef}
className="w-full h-full"
/>
{/* Minimap */}
{minimapExpanded ? (
<div className="absolute bottom-20 right-4 z-20 md:bottom-4 bg-background/95 backdrop-blur rounded-lg border shadow-lg overflow-hidden">
<div className="flex items-center justify-between px-2 py-1 border-b bg-muted/50">
<span className="text-xs font-medium text-muted-foreground"></span>
<button
className="h-6 w-6 flex items-center justify-center hover:bg-accent rounded"
onClick={() => setMinimapExpanded(false)}
title="收起"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="m14 10 7-7"/><path d="m3 21 7-7"/></svg>
</button>
</div>
<canvas
ref={minimapRef}
onMouseDown={handleMinimapMouseDown}
onMouseMove={handleMinimapMouseMove}
onMouseUp={handleMinimapMouseUp}
onMouseLeave={handleMinimapMouseUp}
style={{ display: 'block', cursor: 'crosshair' }}
/>
</div>
) : (
<div className="absolute bottom-20 right-4 z-20 md:bottom-4">
<button
className="h-10 w-10 flex items-center justify-center bg-background/90 backdrop-blur shadow-lg border rounded-lg hover:bg-accent"
onClick={() => setMinimapExpanded(true)}
title="显示缩略图"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" x2="9" y1="3" y2="18"/><line x1="15" x2="15" y1="6" y2="21"/></svg>
</button>
</div>
)}
{/* 自定义右键菜单 */}
{contextMenuNode && (
<div
className="fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{
left: contextMenuPosition.x,
top: contextMenuPosition.y,
pointerEvents: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="px-2 py-1.5 text-sm text-muted-foreground">
{contextMenuNode.fullName}
</div>
<div className="h-px bg-border my-1" />
{canAddFather(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('father', contextMenuNode)
setContextMenuNode(null)
}}
>
<UserPlus className="h-4 w-4 text-blue-500" />
</div>
)}
{canAddMother(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('mother', contextMenuNode)
setContextMenuNode(null)
}}
>
<UserPlus className="h-4 w-4 text-pink-500" />
</div>
)}
{(canAddFather(contextMenuNode) || canAddMother(contextMenuNode)) && (canAddSpouse(contextMenuNode) || canAddChild(contextMenuNode)) && (
<div className="h-px bg-border my-1" />
)}
{canAddSpouse(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('spouse', contextMenuNode)
setContextMenuNode(null)
}}
>
<Users className="h-4 w-4 text-red-500" />
</div>
)}
{canAddChild(contextMenuNode) && (
<div
className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleAddMember('child', contextMenuNode)
setContextMenuNode(null)
}}
>
<Baby className="h-4 w-4 text-green-500" />
</div>
)}
</div>
)}
{/* 点击其他地方关闭菜单 */}
{contextMenuNode && (
<div
className="fixed inset-0 z-40"
onClick={() => setContextMenuNode(null)}
/>
)}
{/* 代数重新计算警告对话框 */}
{pendingAddMember && (
<GenerationWarningDialog
open={showWarningDialog}
onOpenChange={setShowWarningDialog}
memberName={pendingAddMember.member.fullName}
relationType={pendingAddMember.type}
onConfirm={handleConfirmAddParent}
/>
)}
</div>
)
})
D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow'
// 使用memo优化,避免不必要的重新渲染
export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => {
return prevProps.rootId === nextProps.rootId &&
prevProps.members === nextProps.members
})