"use client" import { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo } 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" // 全局类型声明 declare global { interface Window { d3ChartContextMenu?: (event: MouseEvent, memberId: string) => void d3ChartSpouseClick?: (event: Event, spouseId: string) => void } } interface D3OrgChartFlowProps { members: Record 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 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 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( ({ members, rootId, onMemberClick, onExport, relationMode = false, selectedMembers = [] }, ref) => { console.log('D3OrgChartFlow render, selectedMembers:', selectedMembers, 'relationMode:', relationMode) const chartRef = useRef(null) const chartInstanceRef = useRef(null) const [isReady, setIsReady] = useState(false) const [avatarCache, setAvatarCache] = useState>(new Map()) const [contextMenuNode, setContextMenuNode] = useState(null) const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const router = useRouter() const { currentTree } = useFamily() // 构建新增成员的 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') params.set('generation', (member.generation - 1).toString()) // 如果已有母亲,设为配偶 if (member.motherId) { params.set('spouseId', member.motherId) } break case 'mother': params.set('childId', member.id) params.set('gender', 'FEMALE') params.set('generation', (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) => { 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 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() 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}岁` : '' 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}岁`) : '' })() : '', hasChildren: Object.values(members).some(m => m.fatherId === member.id || m.motherId === member.id), ageText: ageText, isFounder: member.isFounder === true }) // 添加子节点到队列 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() 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 = '' } const data = convertToChartData() // 初始化图表 const chart = new OrgChart() .container(chartRef.current) .data(data) .nodeWidth((d: any) => d.data.spouseId ? 260 : 160) // 有配偶时加宽,解决重叠和连线问题 .nodeHeight(() => 165) .childrenMargin(() => 70) .compactMarginBetween(() => 50) // 增加间距 .compactMarginPair(() => 50) .neighbourMargin(() => 50) .siblingsMargin(() => 50) .linkUpdate(function (d: any, i: any, arr: any) { // 中国风连线颜色 - 使用深褐色 d3.select(arr[i]) .attr('stroke', '#78716c') .attr('stroke-width', 2) }) .buttonContent(({ node }: any) => { return `
${node.children ? `` : ``} ${node.data._directSubordinates || 0}
` }) .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 borderColor = isFounder ? '#d97706' // amber-600 更亮的金色 : isDead ? '#78716c' : isMale ? '#92400e' : '#9f1239' const bgGradient = isFounder ? 'linear-gradient(to bottom, #fef3c7, #fde68a, #fcd34d)' // 更亮的金色渐变 : isDead ? 'linear-gradient(to bottom, #f5f5f4, #e7e5e4)' : isMale ? 'linear-gradient(to bottom, #fffbeb, #fef3c7, #fffbeb)' : 'linear-gradient(to bottom, #fff1f2, #fce7f3, #fff1f2)' const avatarBorder = isFounder ? '#d97706' // amber-600 : isDead ? '#78716c' : isMale ? '#b45309' : '#be185d' const textColor = isDead ? '#57534e' : '#1c1917' const subTextColor = isDead ? '#78716c' : '#57534e' const badgeBg = isFounder ? '#d97706' // amber-600 : isDead ? '#57534e' : isMale ? '#92400e' : '#9f1239' const badgeText = '#fef3c7' // 配偶配色 const spIsMale = node.spouseGender === '男' const spIsDead = !!node.spouseDeathYear const spAvatarBorder = spIsDead ? '#78716c' : spIsMale ? '#b45309' : '#be185d' const spTextColor = spIsDead ? '#57534e' : '#1c1917' // 获取头像URL const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null const spAvatarUrl = node.spouseAvatarUrl ? avatarCache.get(node.spouseAvatarUrl) : null // 检查是否被选中 const isSelected = selectedMembers.includes(node.id) const isSpouseSelected = node.spouseId && selectedMembers.includes(node.spouseId) // 动态宽度:有配偶时加宽,与 OrgChart 配置保持一致 const width = hasSpouse ? 260 : 160 return `
${node.generation}
${isSelected ? `
` : ''}
${avatarUrl ? `${node.name}` : node.gender === '女' ? `` : `` }
${node.name}
${node.birthYear ? `
${node.birthYear}${node.deathYear ? ` — ${node.deathYear}` : ' —'}
${node.ageText ? `
${node.ageText}
` : ''} ` : ''}
${hasSpouse ? `
${spAvatarUrl ? `${node.spouseName}` : spIsMale ? `` : `` }
${node.spouseName}
${node.spouseBirthYear ? `
${node.spouseBirthYear}${node.spouseDeathYear ? ` — ${node.spouseDeathYear}` : ' —'}
${node.spouseAgeText ? `
${node.spouseAgeText}
` : ''} ` : ''}
` : ''}
` }) .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() // 初始化时展开所有节点 // 注册全局配偶点击处理函数 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]) // 监听 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 = `` 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 = `` 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() } // 暴露方法给父组件 useImperativeHandle(ref, () => ({ exportPNG: handleExportPNG, exportPDF: handleExportPDF, expandAll: handleExpandAll, collapseAll: handleCollapseAll, fitView: handleFitView, })) return (
{/* 图表容器 */}
{/* 自定义右键菜单 */} {contextMenuNode && (
e.stopPropagation()} >
为 {contextMenuNode.fullName} 添加
{canAddFather(contextMenuNode) && (
{ handleAddMember('father', contextMenuNode) setContextMenuNode(null) }} > 添加父亲
)} {canAddMother(contextMenuNode) && (
{ handleAddMember('mother', contextMenuNode) setContextMenuNode(null) }} > 添加母亲
)} {(canAddFather(contextMenuNode) || canAddMother(contextMenuNode)) && (canAddSpouse(contextMenuNode) || canAddChild(contextMenuNode)) && (
)} {canAddSpouse(contextMenuNode) && (
{ handleAddMember('spouse', contextMenuNode) setContextMenuNode(null) }} > 添加配偶
)} {canAddChild(contextMenuNode) && (
{ handleAddMember('child', contextMenuNode) setContextMenuNode(null) }} > 添加子女
)}
)} {/* 点击其他地方关闭菜单 */} {contextMenuNode && (
setContextMenuNode(null)} /> )}
) }) D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow' // 使用memo优化,避免不必要的重新渲染 export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => { return prevProps.rootId === nextProps.rootId && prevProps.members === nextProps.members })