0.0.3.0
This commit is contained in:
@@ -38,6 +38,7 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
generation: initialData?.generation || maxGeneration,
|
||||
spouseIds: initialData?.spouseIds || [],
|
||||
childrenIds: initialData?.childrenIds || [],
|
||||
tags: initialData?.tags || [],
|
||||
fatherId: initialData?.fatherId,
|
||||
motherId: initialData?.motherId,
|
||||
...initialData,
|
||||
@@ -545,6 +546,59 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<Scroll className="h-5 w-5" />
|
||||
标签 (Tags)
|
||||
</CardTitle>
|
||||
<CardDescription>添加标签以便分类和搜索(按回车添加)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入标签,按回车添加..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const input = e.currentTarget
|
||||
const value = input.value.trim()
|
||||
if (value && !(formData.tags || []).includes(value)) {
|
||||
handleChange('tags', [...(formData.tags || []), value])
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary text-sm rounded-md border border-primary/20"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newTags = formData.tags?.filter((_, i) => i !== index) || []
|
||||
handleChange('tags', newTags)
|
||||
}}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
+27
-18
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Search, Bell, Settings, LogOut, User, ChevronDown, Plus, Trash2 } from "lucide-react"
|
||||
import { BookOpen, Search, Bell, Settings, LogOut, User, ChevronDown, Plus, Trash2, Network, LayoutGrid } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation"
|
||||
import { useSession, signOut } from "next-auth/react"
|
||||
import { useDialog } from "@/components/ui/alert-dialog-custom"
|
||||
|
||||
@@ -31,6 +31,8 @@ export function SiteHeader() {
|
||||
const { searchMembers, searchResults } = useFamily()
|
||||
const { data: session, status } = useSession()
|
||||
const { showAlert, showConfirm, showPrompt } = useDialog()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [familyTrees, setFamilyTrees] = useState<FamilyTree[]>([])
|
||||
@@ -38,6 +40,10 @@ export function SiteHeader() {
|
||||
const [deletingTreeId, setDeletingTreeId] = useState<string | null>(null)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// 获取当前视图模式
|
||||
const currentViewMode = searchParams.get('view') || 'traditional'
|
||||
const isTreePage = pathname === '/tree'
|
||||
|
||||
// 生成带有 treeId 的 URL
|
||||
const getUrlWithTreeId = (path: string) => {
|
||||
@@ -47,6 +53,16 @@ export function SiteHeader() {
|
||||
return path
|
||||
}
|
||||
|
||||
// 切换视图模式
|
||||
const handleViewModeChange = (mode: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('view', mode)
|
||||
if (currentTree?.id) {
|
||||
params.set('treeId', currentTree.id)
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
const getRoleBadge = (role?: string) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
@@ -130,22 +146,13 @@ export function SiteHeader() {
|
||||
|
||||
// 获取家族树列表
|
||||
const loadFamilyTrees = useCallback(() => {
|
||||
// 只在 session 加载完成且用户已登录时才获取
|
||||
if (status === 'loading') {
|
||||
return
|
||||
}
|
||||
if (!session?.user?.id || status !== 'authenticated') return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
setFamilyTrees([])
|
||||
setCurrentTree(null)
|
||||
return
|
||||
}
|
||||
|
||||
fetch('/api/trees')
|
||||
fetch('/api/trees', { signal: controller.signal })
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
@@ -167,11 +174,13 @@ export function SiteHeader() {
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// 只在非 abort 错误时才记录
|
||||
if (err.name !== 'AbortError') {
|
||||
// 忽略 abort 错误和 fetch 错误(可能是页面切换导致的)
|
||||
if (err.name !== 'AbortError' && err.message !== 'Failed to fetch') {
|
||||
console.error('获取家族树列表失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [session?.user?.id, status])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react"
|
||||
import * as d3 from "d3"
|
||||
// @ts-ignore - d3-org-chart没有TypeScript类型定义
|
||||
import { OrgChart } from "d3-org-chart"
|
||||
import { Download, FileText } from "lucide-react"
|
||||
import type { FamilyMember } from "@/types/family"
|
||||
import jsPDF from "jspdf"
|
||||
import { db } from "@/lib/db"
|
||||
|
||||
interface D3OrgChartFlowProps {
|
||||
members: Record<string, FamilyMember>
|
||||
rootId: string
|
||||
onMemberClick?: (memberId: string) => void
|
||||
onExport?: () => void
|
||||
}
|
||||
|
||||
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
|
||||
avatarImageId: 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
|
||||
}
|
||||
|
||||
export const D3OrgChartFlow = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
|
||||
({ members, rootId, onMemberClick, onExport }, ref) => {
|
||||
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 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中
|
||||
let spouse = null
|
||||
if (member.spouseIds?.[0] && members[member.spouseIds[0]]) {
|
||||
spouse = members[member.spouseIds[0]]
|
||||
}
|
||||
|
||||
// 确定父节点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' ? '男' : '女',
|
||||
avatarImageId: member.avatarImageId,
|
||||
spouseName: spouse?.fullName || '',
|
||||
spouseId: spouse?.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
|
||||
}
|
||||
|
||||
// 加载所有成员的头像
|
||||
useEffect(() => {
|
||||
const loadAvatars = async () => {
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
for (const member of Object.values(members)) {
|
||||
if (member.avatarImageId) {
|
||||
try {
|
||||
const image = await db.images.get(member.avatarImageId)
|
||||
if (image) {
|
||||
const url = URL.createObjectURL(image.blob)
|
||||
cache.set(member.avatarImageId, url)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load avatar for ${member.fullName}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAvatarCache(cache)
|
||||
}
|
||||
|
||||
loadAvatars()
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
avatarCache.forEach(url => URL.revokeObjectURL(url))
|
||||
}
|
||||
}, [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(() => 120)
|
||||
.childrenMargin(() => 50)
|
||||
.compactMarginBetween(() => 35)
|
||||
.compactMarginPair(() => 35)
|
||||
.neighbourMargin(() => 35)
|
||||
.siblingsMargin(() => 35)
|
||||
.linkUpdate(function (d: any, i: any, arr: any) {
|
||||
// 加深连线颜色
|
||||
d3.select(arr[i])
|
||||
.attr('stroke', '#475569')
|
||||
.attr('stroke-width', 2.5)
|
||||
})
|
||||
.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 color = node.gender === '男' ? '#3b82f6' : '#ec4899'
|
||||
const bgColor = node.gender === '男' ? '#dbeafe' : '#fce7f3'
|
||||
|
||||
// 获取头像URL
|
||||
const avatarUrl = node.avatarImageId ? avatarCache.get(node.avatarImageId) : null
|
||||
|
||||
return `
|
||||
<div style="
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
background: ${node.deathYear ? '#fafafa' : 'white'};
|
||||
border: 2px solid ${color};
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
" onmouseover="this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)'" onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.1)'">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 5px;">
|
||||
<div style="
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: ${bgColor};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: ${color};
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid ${color}40;
|
||||
">
|
||||
${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="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 24px; height: 24px;"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`
|
||||
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 24px; height: 24px;"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`
|
||||
}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
${node.name}
|
||||
</div>
|
||||
<div style="font-size: 9px; color: #6b7280; padding: 1px 4px; background: #f3f4f6; border-radius: 3px; display: inline-block;">
|
||||
${node.generation}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 10px; color: #6b7280; line-height: 1.3;">
|
||||
${node.birthYear ? `<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1px;">
|
||||
<span>${node.birthYear}${node.deathYear ? ` - ${node.deathYear}` : ''}</span>
|
||||
${node.ageText ? `<span style="font-size: 9px; color: #9ca3af;">${node.ageText}</span>` : ''}
|
||||
</div>` : ''}
|
||||
${node.birthPlace ? `<div style="margin-bottom: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
${node.birthPlace}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
${node.spouseName && node.spouseId ? `
|
||||
<div
|
||||
class="spouse-area"
|
||||
data-spouse-id="${node.spouseId}"
|
||||
style="
|
||||
margin-top: 4px;
|
||||
padding: 3px 5px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
color: #4b5563;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
"
|
||||
onmouseover="this.style.borderColor='#3b82f6'; this.style.background='#eff6ff'; this.style.color='#1e40af'"
|
||||
onmouseout="this.style.borderColor='#e5e7eb'; this.style.background='#f9fafb'; this.style.color='#4b5563'"
|
||||
>
|
||||
配偶: ${node.spouseName}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${node.deathYear ? `
|
||||
<div style="
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #737373;
|
||||
border: 2px solid white;
|
||||
"></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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}, 100)
|
||||
|
||||
chartInstanceRef.current = chart
|
||||
setIsReady(true)
|
||||
|
||||
return () => {
|
||||
// 清理
|
||||
if (chartRef.current) {
|
||||
chartRef.current.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}, [members, rootId, onMemberClick, avatarCache])
|
||||
|
||||
// 导出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' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
D3OrgChartFlow.displayName = 'D3OrgChartFlow'
|
||||
@@ -45,7 +45,7 @@ export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, gender, clas
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarFallback className="bg-muted flex items-center justify-center">
|
||||
{gender === 'female' ? (
|
||||
{gender === 'FEMALE' ? (
|
||||
<CircleUserRound className="h-8 w-8 text-pink-400" />
|
||||
) : (
|
||||
<CircleUser className="h-8 w-8 text-blue-400" />
|
||||
|
||||
Reference in New Issue
Block a user