This commit is contained in:
freedakgmail
2025-12-09 18:52:00 +08:00
parent debcce71bf
commit 09809c0e49
156 changed files with 1150 additions and 1330 deletions
+29 -5
View File
@@ -11,12 +11,14 @@ import { useFamily } from "@/context/family-context"
import { useToast } from "@/components/ui/use-toast"
import type { FamilyMember } from "@/types/family"
export type RelationType = "child" | "spouse" | "father" | "mother"
interface AddRelationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
member: FamilyMember
relationType: "child" | "spouse"
onCreateNew: (prefillName: string, relationType: "child" | "spouse") => void
relationType: RelationType
onCreateNew: (prefillName: string, relationType: RelationType) => void
}
interface CandidateMeta {
@@ -73,11 +75,21 @@ export function AddRelationDialog({
// 非男女成员,允许填补任一缺失的父母
if (candidate.fatherId && candidate.motherId) return false
}
} else {
} else if (relationType === "spouse") {
if (candidate.generation !== member.generation) return false
if (member.spouseIds?.includes(candidate.id)) return false
if (candidate.spouseIds?.includes(member.id)) return false
if (candidate.gender === member.gender) return false
} else if (relationType === "father") {
// 父亲必须是男性,世代比当前成员小1
if (candidate.gender !== "MALE") return false
if (candidate.generation !== (member.generation - 1)) return false
if (member.fatherId === candidate.id) return false
} else if (relationType === "mother") {
// 母亲必须是女性,世代比当前成员小1
if (candidate.gender !== "FEMALE") return false
if (candidate.generation !== (member.generation - 1)) return false
if (member.motherId === candidate.id) return false
}
return true
@@ -113,7 +125,7 @@ export function AddRelationDialog({
title: "关联成功",
description: `已将 ${target.fullName} 标记为 ${member.fullName} 的子女。`
})
} else {
} else if (relationType === "spouse") {
const existing = new Set(member.spouseIds || [])
existing.add(target.id)
await updateMember(member.id, { spouseIds: Array.from(existing) })
@@ -121,6 +133,18 @@ export function AddRelationDialog({
title: "关联成功",
description: `${member.fullName}${target.fullName} 已建立配偶关系。`
})
} else if (relationType === "father") {
await updateMember(member.id, { fatherId: target.id })
toast({
title: "关联成功",
description: `已将 ${target.fullName} 设为 ${member.fullName} 的父亲。`
})
} else if (relationType === "mother") {
await updateMember(member.id, { motherId: target.id })
toast({
title: "关联成功",
description: `已将 ${target.fullName} 设为 ${member.fullName} 的母亲。`
})
}
onOpenChange(false)
} catch (error) {
@@ -144,7 +168,7 @@ export function AddRelationDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{relationType === "child" ? "子女" : "配偶"}</DialogTitle>
<DialogTitle>{relationType === "child" ? "子女" : relationType === "spouse" ? "配偶" : relationType === "father" ? "父亲" : "母亲"}</DialogTitle>
<DialogDescription>
</DialogDescription>
+4 -2
View File
@@ -191,11 +191,13 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
.filter(Boolean) || []
// 确定父节点ID - 只有根节点的parentId为null
// 重要:parentId 必须指向已经在 visited 集合中的节点,否则 d3-org-chart 会报 missing 错误
let parentId: string | null = null
if (currentId !== rootId) {
// 根据姓氏判断主线,优先选择同姓的父母
const father = member.fatherId ? members[member.fatherId] : null
const mother = member.motherId ? members[member.motherId] : null
// 同时确保父母已经被访问过(在 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
+84 -4
View File
@@ -28,6 +28,10 @@ interface FamilyNodeProps {
selectedMembers?: string[]
canEdit?: boolean
onAddRelation?: (type: "child" | "spouse") => void
// 折叠功能
hasChildren?: boolean
isCollapsed?: boolean
onToggleCollapse?: () => void
}
// 格式化日期显示(只显示年份)
@@ -64,7 +68,10 @@ function PersonCard({
relationMode,
isSelected,
canEdit = true,
onAddRelation
onAddRelation,
hasChildren = false,
isCollapsed = false,
onToggleCollapse
}: {
member: FamilyMember
isRoot?: boolean
@@ -75,6 +82,9 @@ function PersonCard({
isSelected?: boolean
canEdit?: boolean
onAddRelation?: (type: "child" | "spouse") => void
hasChildren?: boolean
isCollapsed?: boolean
onToggleCollapse?: () => void
}) {
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarUrl)
const router = useRouter()
@@ -322,6 +332,35 @@ function PersonCard({
</div>
)}
</div>
{/* 折叠/展开按钮 */}
{hasChildren && onToggleCollapse && (
<button
onClick={(e) => {
e.stopPropagation()
onToggleCollapse()
}}
className={cn(
"absolute -bottom-3 left-1/2 -translate-x-1/2 z-20",
"w-6 h-6 rounded-full flex items-center justify-center",
"bg-white border-2 shadow-md transition-all hover:scale-110",
isCollapsed
? "border-amber-500 text-amber-600"
: "border-stone-400 text-stone-500"
)}
title={isCollapsed ? "展开子孙" : "折叠子孙"}
>
{isCollapsed ? (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
)}
</button>
)}
</div>
)
@@ -410,18 +449,59 @@ function PersonCard({
)
}
export function FamilyNode({ member, spouse, spouses, isRoot, onSelect, isHighlighted, relationMode, isSelected, selectedMembers = [], canEdit = true, onAddRelation }: FamilyNodeProps) {
export function FamilyNode({
member,
spouse,
spouses,
isRoot,
onSelect,
isHighlighted,
relationMode,
isSelected,
selectedMembers = [],
canEdit = true,
onAddRelation,
hasChildren = false,
isCollapsed = false,
onToggleCollapse
}: FamilyNodeProps) {
// 优先使用 spouses 数组,如果没有则使用单个 spouse
const spouseList = spouses || (spouse ? [spouse] : [])
if (spouseList.length === 0) {
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} onAddRelation={onAddRelation} />
return (
<PersonCard
member={member}
isRoot={isRoot}
onSelect={onSelect}
isHighlighted={isHighlighted}
relationMode={relationMode}
isSelected={isSelected}
canEdit={canEdit}
onAddRelation={onAddRelation}
hasChildren={hasChildren}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
)
}
// 有配偶时,并排显示所有配偶
return (
<div className="flex items-center gap-2">
<PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} onAddRelation={onAddRelation} />
<PersonCard
member={member}
isRoot={isRoot}
onSelect={onSelect}
isHighlighted={isHighlighted}
relationMode={relationMode}
isSelected={isSelected}
canEdit={canEdit}
onAddRelation={onAddRelation}
hasChildren={hasChildren}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
{spouseList.map((spouseMember, index) => (
<div key={spouseMember.id} className="flex items-center gap-2">
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
+159 -25
View File
@@ -1,7 +1,8 @@
"use client"
import type React from "react"
import type { FamilyMember } from "@/types/family"
import { FamilyNode } from "./family-node"
import { AddRelationDialog } from "./add-relation-dialog"
import { AddRelationDialog, type RelationType } from "./add-relation-dialog"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
import { useRouter } from "next/navigation"
@@ -38,7 +39,66 @@ export function TreeLayout({
const [connections, setConnections] = useState<Connection[]>([])
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [relationDialog, setRelationDialog] = useState<{ member: FamilyMember; type: "child" | "spouse" } | null>(null)
// 重新计算连线的函数(使用 ref 避免依赖问题)
const recalculateConnections = useCallback(() => {
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect || nodeRefs.current.size === 0) return
const newConnections: Connection[] = []
nodeRefs.current.forEach((nodeElement, nodeId) => {
const member = getMember(nodeId)
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
const parentRect = nodeElement.getBoundingClientRect()
const parentCenterX = parentRect.left + parentRect.width / 2 - containerRect.left
const parentBottomY = parentRect.bottom - containerRect.top
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement) return null
const childRect = childElement.getBoundingClientRect()
return {
id: childId,
centerX: childRect.left + childRect.width / 2 - containerRect.left,
topY: childRect.top - containerRect.top
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
if (childPositions.length === 0) return
const verticalLineY = parentBottomY + 24
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
newConnections.push({
from: { x: leftmostX, y: verticalLineY },
to: { x: rightmostX, y: verticalLineY },
type: 'horizontal'
})
}
childPositions.forEach(child => {
newConnections.push({
from: { x: child.centerX, y: verticalLineY },
to: { x: child.centerX, y: child.topY },
type: 'vertical'
})
})
})
setConnections(newConnections)
}, [getMember])
// 节点间距配置
const HORIZONTAL_GAP = 32 // 2rem = 32px
const VERTICAL_GAP = 48 // 3rem = 48px
@@ -71,14 +131,6 @@ export function TreeLayout({
const parentCenterX = parentRect.left + parentRect.width / 2 - containerRect.left
const parentBottomY = parentRect.bottom - containerRect.top
// 父节点到水平线的垂直连线
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
// 获取所有子节点的位置
const childPositions = member.childrenIds
.map(childId => {
@@ -95,6 +147,14 @@ export function TreeLayout({
if (childPositions.length === 0) return
// 父节点到水平线的垂直连线
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
// 如果有多个子节点,绘制水平连线
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
@@ -137,6 +197,7 @@ export function TreeLayout({
}
}, [calculateConnections, treeData])
// 预计算排序后的子节点映射,避免每次渲染时重复计算
const sortedChildrenMap = useMemo(() => {
const map = new Map<string, string[]>()
@@ -162,13 +223,57 @@ export function TreeLayout({
if (!root) return null
// 姓名分解函数:将全名分解为姓和名
const parseName = (fullName: string): { surname: string; givenName: string } => {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
// 常见复姓列表
const compoundSurnames = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
// 检查是否是复姓
for (const compound of compoundSurnames) {
if (name.startsWith(compound) && name.length > compound.length) {
return {
surname: compound,
givenName: name.slice(compound.length)
}
}
}
// 单姓处理
if (name.length >= 2) {
return {
surname: name.charAt(0),
givenName: name.slice(1)
}
}
// 只有一个字,作为姓
return { surname: name, givenName: '' }
}
// 递归渲染树节点 - 使用 memo 优化
const handleCreateNewRelation = (prefillName: string, relationType: "child" | "spouse") => {
const handleCreateNewRelation = (prefillName: string, relationType: RelationType) => {
if (!relationDialog) return
const params = new URLSearchParams()
params.set("generation", relationType === "child" ? String(relationDialog.member.generation + 1) : String(relationDialog.member.generation))
params.set("prefillName", prefillName)
// 分解姓名
const { surname, givenName } = parseName(prefillName)
if (surname) params.set("surname", surname)
if (givenName) params.set("givenName", givenName)
if (relationType === "child") {
params.set("generation", String(relationDialog.member.generation + 1))
if (relationDialog.member.gender === "MALE") {
params.set("fatherId", relationDialog.member.id)
if (relationDialog.member.spouseIds?.length) {
@@ -180,10 +285,34 @@ export function TreeLayout({
params.set("fatherId", relationDialog.member.spouseIds[0])
}
}
} else {
} else if (relationType === "spouse") {
params.set("generation", String(relationDialog.member.generation))
params.set("spouseId", relationDialog.member.id)
params.set("gender", relationDialog.member.gender === "MALE" ? "FEMALE" : "MALE")
// 如果当前成员有子女,将他们也作为新配偶的子女
if (relationDialog.member.childrenIds && relationDialog.member.childrenIds.length > 0) {
relationDialog.member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
} else if (relationType === "father") {
params.set("generation", String(relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "MALE")
// 如果已有母亲,设为配偶
if (relationDialog.member.motherId) {
params.set("spouseId", relationDialog.member.motherId)
}
} else if (relationType === "mother") {
params.set("generation", String(relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "FEMALE")
// 如果已有父亲,设为配偶
if (relationDialog.member.fatherId) {
params.set("spouseId", relationDialog.member.fatherId)
}
}
const treeParams = new URLSearchParams(window.location.search)
treeParams.forEach((value, key) => {
params.set(key, value)
@@ -191,22 +320,26 @@ export function TreeLayout({
window.location.href = `/members/new?${params.toString()}`
}
const TreeNode = memo(function TreeNode({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) {
// 递归渲染树节点
function renderTreeNode(memberId: string, isRoot: boolean = false): React.ReactNode {
const member = getMember(memberId)
if (!member) return null
const hasChildren = member.childrenIds && member.childrenIds.length > 0
// 计算实际要显示的子节点(过滤掉不存在的成员)
const visibleChildren = hasChildren
? (sortedChildrenMap.get(memberId) || []).filter(childId => getMember(childId) !== undefined)
: []
const hasVisibleChildren = visibleChildren.length > 0
// 获取所有配偶
const spouses = member.spouseIds && member.spouseIds.length > 0
? member.spouseIds.map(id => getMember(id)).filter(Boolean) as FamilyMember[]
: []
// 使用预计算的排序结果
const sortedChildren = sortedChildrenMap.get(memberId) || []
return (
<div className="flex flex-col items-center">
<div key={memberId} className="flex flex-col items-center">
{/* 节点本身 */}
<div
ref={(el) => {
@@ -229,11 +362,14 @@ export function TreeLayout({
selectedMembers={selectedMembers}
canEdit={canEdit}
onAddRelation={(type) => setRelationDialog({ member, type })}
hasChildren={false}
isCollapsed={false}
onToggleCollapse={undefined}
/>
</div>
{/* 子节点 */}
{hasChildren && (
{hasVisibleChildren && (
<div
className="flex items-start relative"
style={{
@@ -241,14 +377,12 @@ export function TreeLayout({
gap: `${HORIZONTAL_GAP}px`
}}
>
{sortedChildren.map((childId) => (
<TreeNode key={childId} memberId={childId} />
))}
{visibleChildren.map((childId) => renderTreeNode(childId, false))}
</div>
)}
</div>
)
})
}
return (
<div className="relative">
@@ -284,8 +418,8 @@ export function TreeLayout({
</div>
{/* 树节点层 */}
<div className="relative" style={{ zIndex: 10 }}>
<TreeNode memberId={rootId} isRoot={true} />
<div className="relative" style={{ zIndex: 10 }} key="tree-root">
{renderTreeNode(rootId, true)}
</div>
</div>