686 lines
25 KiB
TypeScript
686 lines
25 KiB
TypeScript
"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
|
|
}
|
|
}
|
|
|
|
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 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
|
|
hasChildren: boolean
|
|
ageText: string
|
|
}
|
|
|
|
// 计算年龄
|
|
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()
|
|
|
|
// 构建新增成员的 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<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
|
|
let parentId: string | null = null
|
|
if (currentId !== rootId) {
|
|
// 根据姓氏判断主线,优先选择同姓的父母
|
|
const father = member.fatherId ? members[member.fatherId] : null
|
|
const mother = 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,
|
|
hasChildren: Object.values(members).some(m => m.fatherId === member.id || m.motherId === member.id),
|
|
ageText: ageText
|
|
})
|
|
|
|
// 添加子节点到队列
|
|
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 = ''
|
|
}
|
|
|
|
const data = convertToChartData()
|
|
|
|
// 初始化图表
|
|
const chart = new OrgChart()
|
|
.container(chartRef.current)
|
|
.data(data)
|
|
.nodeWidth(() => 160)
|
|
.nodeHeight(() => 165)
|
|
.childrenMargin(() => 70)
|
|
.compactMarginBetween(() => 40)
|
|
.compactMarginPair(() => 40)
|
|
.neighbourMargin(() => 40)
|
|
.siblingsMargin(() => 40)
|
|
.linkUpdate(function (d: any, i: any, arr: any) {
|
|
// 中国风连线颜色 - 使用深褐色
|
|
d3.select(arr[i])
|
|
.attr('stroke', '#78716c')
|
|
.attr('stroke-width', 2)
|
|
})
|
|
.buttonContent(({ node }: any) => {
|
|
return `<div style="color:#716E7B;border-radius:5px;padding:4px;font-size:10px;margin:auto auto;background-color:white;border: 1px solid #E4E2E9"> <span style="font-size:9px">${node.children ? `<i class="fas fa-angle-up"></i>` : `<i class="fas fa-angle-down"></i>`}</span> ${node.data._directSubordinates || 0} </div>`
|
|
})
|
|
.nodeContent((d: any) => {
|
|
const node = d.data
|
|
const isMale = node.gender === '男'
|
|
const isDead = !!node.deathYear
|
|
|
|
// 庄严正式的中国风配色
|
|
const borderColor = isDead ? '#78716c' : isMale ? '#92400e' : '#9f1239'
|
|
const bgGradient = isDead
|
|
? 'linear-gradient(to bottom, #f5f5f4, #e7e5e4)'
|
|
: isMale
|
|
? 'linear-gradient(to bottom, #fffbeb, #fef3c7, #fffbeb)'
|
|
: 'linear-gradient(to bottom, #fff1f2, #fce7f3, #fff1f2)'
|
|
const avatarBorder = isDead ? '#78716c' : isMale ? '#b45309' : '#be185d'
|
|
const textColor = isDead ? '#57534e' : '#1c1917'
|
|
const subTextColor = isDead ? '#78716c' : '#57534e'
|
|
const badgeBg = isDead ? '#57534e' : isMale ? '#92400e' : '#9f1239'
|
|
const badgeText = '#fef3c7'
|
|
|
|
// 获取头像URL
|
|
const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null
|
|
|
|
// 检查是否被选中
|
|
const isSelected = selectedMembers.includes(node.id)
|
|
|
|
return `
|
|
<div
|
|
data-member-id="${node.id}"
|
|
style="
|
|
position: relative;
|
|
width: 150px;
|
|
height: 155px;
|
|
box-sizing: border-box;
|
|
background: ${bgGradient};
|
|
border: 3px solid ${borderColor};
|
|
padding: 14px 10px 10px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
"
|
|
onmouseover="this.style.boxShadow='0 6px 20px rgba(0,0,0,0.2)'; this.style.transform='scale(1.02)'"
|
|
onmouseout="this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'; this.style.transform='scale(1)'"
|
|
>
|
|
<!-- 内边框装饰 -->
|
|
<div style="position: absolute; inset: 3px; border: 1px solid ${borderColor}40; pointer-events: none;"></div>
|
|
|
|
<!-- 四角装饰 - 庄严角花 -->
|
|
<div style="position: absolute; top: 0; left: 0; width: 12px; height: 12px; border-top: 3px solid ${borderColor}; border-left: 3px solid ${borderColor};"></div>
|
|
<div style="position: absolute; top: 0; right: 0; width: 12px; height: 12px; border-top: 3px solid ${borderColor}; border-right: 3px solid ${borderColor};"></div>
|
|
<div style="position: absolute; bottom: 0; left: 0; width: 12px; height: 12px; border-bottom: 3px solid ${borderColor}; border-left: 3px solid ${borderColor};"></div>
|
|
<div style="position: absolute; bottom: 0; right: 0; width: 12px; height: 12px; border-bottom: 3px solid ${borderColor}; border-right: 3px solid ${borderColor};"></div>
|
|
|
|
<!-- 世代标签 - 庄严印章风格 -->
|
|
<div style="
|
|
position: absolute;
|
|
top: -12px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: ${badgeBg};
|
|
color: ${badgeText};
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
padding: 3px 12px;
|
|
border: 2px solid ${borderColor};
|
|
font-family: serif;
|
|
letter-spacing: 2px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
">
|
|
${node.generation}
|
|
</div>
|
|
|
|
<!-- 选中标记 -->
|
|
${isSelected ? `
|
|
<div style="position: absolute; top: 6px; right: 6px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- 头像和姓名区域 -->
|
|
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; margin-top: 6px;">
|
|
<!-- 头像 - 方形庄严风格 -->
|
|
<div style="
|
|
width: 50px;
|
|
height: 50px;
|
|
background: rgba(255,255,255,0.9);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
border: 2px solid ${avatarBorder};
|
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
|
|
">
|
|
${avatarUrl
|
|
? `<img src="${avatarUrl}" alt="${node.name}" style="width: 100%; height: 100%; object-fit: cover;" />`
|
|
: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="${isDead ? '#78716c' : isMale ? '#b45309' : '#be185d'}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`
|
|
}
|
|
</div>
|
|
|
|
<!-- 姓名 -->
|
|
<div style="
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: ${textColor};
|
|
font-family: serif;
|
|
letter-spacing: 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
">
|
|
<span>${node.name}</span>
|
|
<span style="width: 7px; height: 7px; border-radius: 50%; background-color: ${isDead ? '#78716c' : '#16a34a'}; box-shadow: 0 0 3px ${isDead ? '#78716c' : '#16a34a'};"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 生卒年信息 -->
|
|
<div style="text-align: center; margin-top: 6px;">
|
|
${node.birthYear ? `
|
|
<div style="font-size: 11px; color: ${subTextColor}; font-family: monospace; letter-spacing: 1px;">
|
|
${node.birthYear}${node.deathYear ? ` — ${node.deathYear}` : ' —'}
|
|
</div>
|
|
${node.ageText ? `<div style="font-size: 10px; color: ${subTextColor}; font-family: serif; margin-top: 2px;">${node.ageText}</div>` : ''}
|
|
` : ''}
|
|
</div>
|
|
|
|
<!-- 配偶信息 -->
|
|
${node.spouseName && node.spouseId ? `
|
|
<div
|
|
class="spouse-area"
|
|
data-spouse-id="${node.spouseId}"
|
|
style="
|
|
margin-top: 6px;
|
|
padding: 4px 8px;
|
|
background: rgba(120, 113, 108, 0.08);
|
|
border: 1px dashed ${borderColor};
|
|
font-size: 10px;
|
|
color: ${subTextColor};
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
text-align: center;
|
|
font-family: serif;
|
|
letter-spacing: 1px;
|
|
"
|
|
onmouseover="this.style.background='rgba(120, 113, 108, 0.15)'; this.style.borderStyle='solid'"
|
|
onmouseout="this.style.background='rgba(120, 113, 108, 0.08)'; this.style.borderStyle='dashed'"
|
|
>
|
|
配偶:${node.spouseName}
|
|
</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() // 初始化时展开所有节点
|
|
|
|
// 添加配偶区域点击事件监听
|
|
setTimeout(() => {
|
|
const spouseAreas = chartRef.current?.querySelectorAll('.spouse-area')
|
|
spouseAreas?.forEach((area) => {
|
|
area.addEventListener('click', (e) => {
|
|
e.stopPropagation() // 阻止事件冒泡到节点
|
|
const spouseId = (area as HTMLElement).getAttribute('data-spouse-id')
|
|
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)
|
|
}, 100)
|
|
|
|
chartInstanceRef.current = chart
|
|
setIsReady(true)
|
|
|
|
return () => {
|
|
// 清理
|
|
if (chartRef.current) {
|
|
chartRef.current.innerHTML = ''
|
|
}
|
|
// 清理全局函数
|
|
if (window.d3ChartContextMenu) {
|
|
delete window.d3ChartContextMenu
|
|
}
|
|
}
|
|
}, [members, rootId, onMemberClick, avatarCache])
|
|
|
|
// 监听selectedMembers变化,动态显示/隐藏勾选图标
|
|
// 逻辑已移至tree/page.tsx以避免组件重新渲染
|
|
|
|
// 导出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 (
|
|
<div className="w-full h-full bg-background rounded-lg border relative" key={`d3-chart-${rootId}`}>
|
|
{/* 图表容器 */}
|
|
<div
|
|
ref={chartRef}
|
|
className="w-full h-full"
|
|
style={{ overflow: 'hidden' }}
|
|
/>
|
|
|
|
{/* 自定义右键菜单 */}
|
|
{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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
D3OrgChartFlowComponent.displayName = 'D3OrgChartFlow'
|
|
|
|
// 使用memo优化,避免不必要的重新渲染
|
|
export const D3OrgChartFlow = memo(D3OrgChartFlowComponent, (prevProps, nextProps) => {
|
|
return prevProps.rootId === nextProps.rootId &&
|
|
prevProps.members === nextProps.members
|
|
})
|