3f005779ae
PersonCard(无配偶)原高 177px,WithSpouse(有配偶)原高 153px, 同行混排时高度不齐导致错行感。两种卡片统一最小高度 177px, WithSpouse 内容区域垂直居中。
760 lines
28 KiB
TypeScript
760 lines
28 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import type { FamilyMember } from "@/types/family"
|
|
import { cn } from "@/lib/utils"
|
|
import { useAvatarCache } from "@/hooks/use-avatar-cache"
|
|
import { CircleUser, CircleUserRound, Heart, UserPlus, Users, Baby } from "lucide-react"
|
|
import { MemberNameWithStatus } from "@/components/member-name-with-status"
|
|
import { useRouter } from "next/navigation"
|
|
import { useFamily } from "@/context/family-context"
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuLabel,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu"
|
|
import { GenerationWarningDialog } from "@/components/tree/generation-warning-dialog"
|
|
import { shouldShowGenerationWarning } from "@/lib/generation-utils"
|
|
|
|
interface FamilyNodeProps {
|
|
member: FamilyMember
|
|
spouse?: FamilyMember
|
|
spouses?: FamilyMember[]
|
|
isRoot?: boolean
|
|
onSelect?: (member: FamilyMember) => void
|
|
isHighlighted?: boolean
|
|
highlightedMemberId?: string | null
|
|
relationMode?: boolean
|
|
isSelected?: boolean
|
|
selectedMembers?: string[]
|
|
canEdit?: boolean
|
|
onAddRelation?: (type: "child" | "spouse") => void
|
|
// 折叠功能
|
|
hasChildren?: boolean
|
|
isCollapsed?: boolean
|
|
onToggleCollapse?: () => void
|
|
}
|
|
|
|
// 格式化日期显示(只显示年份)
|
|
function formatYear(dateString?: string): string {
|
|
if (!dateString) return ''
|
|
const year = dateString.split('-')[0]
|
|
return year
|
|
}
|
|
|
|
// 计算年龄
|
|
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
|
|
}
|
|
|
|
// 单个人员卡片组件
|
|
function PersonCard({
|
|
member,
|
|
isRoot,
|
|
isSpouse,
|
|
onSelect,
|
|
isHighlighted,
|
|
relationMode,
|
|
isSelected,
|
|
canEdit = true,
|
|
onAddRelation,
|
|
hasChildren = false,
|
|
isCollapsed = false,
|
|
onToggleCollapse
|
|
}: {
|
|
member: FamilyMember
|
|
isRoot?: boolean
|
|
isSpouse?: boolean
|
|
onSelect?: (member: FamilyMember) => void
|
|
isHighlighted?: boolean
|
|
relationMode?: boolean
|
|
isSelected?: boolean
|
|
canEdit?: boolean
|
|
onAddRelation?: (type: "child" | "spouse") => void
|
|
hasChildren?: boolean
|
|
isCollapsed?: boolean
|
|
onToggleCollapse?: () => void
|
|
}) {
|
|
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarUrl)
|
|
const router = useRouter()
|
|
const { currentTree } = useFamily()
|
|
|
|
// 代数重新计算警告对话框状态
|
|
const [showWarningDialog, setShowWarningDialog] = useState(false)
|
|
const [pendingRelationType, setPendingRelationType] = useState<'father' | 'mother' | null>(null)
|
|
|
|
const birthYear = formatYear(member.birthDate)
|
|
const deathYear = formatYear(member.deathDate)
|
|
const age = calculateAge(member.birthDate, member.deathDate)
|
|
|
|
const lifeSpan = birthYear
|
|
? deathYear
|
|
? `${birthYear}-${deathYear}`
|
|
: `${birthYear}-`
|
|
: ''
|
|
|
|
const ageText = age !== null
|
|
? member.deathDate
|
|
? `享年${age}岁`
|
|
: `${age}岁`
|
|
: ''
|
|
|
|
// 判断可以添加的关系
|
|
const canAddFather = !member.fatherId
|
|
const canAddMother = !member.motherId
|
|
const canAddSpouse = true // 配偶总是可以添加
|
|
const canAddChild = true // 子女总是可以添加
|
|
|
|
// 构建新增成员的 URL
|
|
const buildAddUrl = (type: 'father' | 'mother' | 'spouse' | 'child') => {
|
|
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') => {
|
|
// 检查是否需要显示代数重新计算警告
|
|
if ((type === 'father' || type === 'mother') && shouldShowGenerationWarning(member, type)) {
|
|
setPendingRelationType(type)
|
|
setShowWarningDialog(true)
|
|
return
|
|
}
|
|
|
|
const url = buildAddUrl(type)
|
|
// 使用 setTimeout 延迟跳转,确保菜单关闭后再执行
|
|
setTimeout(() => {
|
|
window.location.href = url
|
|
}, 100)
|
|
}
|
|
|
|
// 确认添加父母后的处理
|
|
const handleConfirmAddParent = () => {
|
|
if (pendingRelationType) {
|
|
const url = buildAddUrl(pendingRelationType)
|
|
setTimeout(() => {
|
|
window.location.href = url
|
|
}, 100)
|
|
setPendingRelationType(null)
|
|
}
|
|
}
|
|
|
|
// 是否显示右键菜单(有可添加的选项时才显示)
|
|
const hasAddOptions = canEdit && (canAddFather || canAddMother || canAddSpouse || canAddChild)
|
|
|
|
// 庄严正式的中国风配色
|
|
const isDead = !!member.deathDate
|
|
const isMale = member.gender === 'MALE'
|
|
// 使用 isRoot prop 而不是 isFounder 字段
|
|
const isFounder = isRoot === true
|
|
|
|
const cardContent = (
|
|
<div
|
|
data-member-id={member.id}
|
|
className={cn(
|
|
"family-node-card relative flex flex-col items-center p-4 transition-all duration-300 cursor-pointer w-[140px] min-h-[177px] justify-center",
|
|
// 现代简约中国风 - 圆角卡片
|
|
"rounded-xl",
|
|
"hover:scale-[1.03] hover:-translate-y-1",
|
|
// 柔和阴影效果
|
|
isHighlighted
|
|
? "shadow-[0_8px_30px_rgba(234,179,8,0.4)] ring-2 ring-yellow-400"
|
|
: "shadow-[0_4px_20px_rgba(0,0,0,0.08)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.15)]",
|
|
// 根据状态设置背景 - 更柔和的渐变
|
|
isHighlighted
|
|
? "bg-gradient-to-br from-yellow-50 via-amber-50 to-yellow-100 dark:from-yellow-950/60 dark:to-amber-900/40"
|
|
: isFounder || isRoot
|
|
? "bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 dark:from-amber-950/50 dark:to-orange-900/30"
|
|
: isSpouse
|
|
? "bg-gradient-to-br from-rose-50/80 via-pink-50/60 to-rose-50/80 dark:from-rose-950/30 dark:to-pink-900/20"
|
|
: isDead
|
|
? "bg-gradient-to-br from-stone-100 via-gray-50 to-stone-100 dark:from-stone-900/50 dark:to-gray-800/30"
|
|
: isMale
|
|
? "bg-gradient-to-br from-sky-50 via-blue-50/50 to-indigo-50/30 dark:from-sky-950/40 dark:to-blue-900/20"
|
|
: "bg-gradient-to-br from-rose-50 via-pink-50/50 to-fuchsia-50/30 dark:from-rose-950/40 dark:to-pink-900/20",
|
|
relationMode && "hover:ring-2 hover:ring-primary/50"
|
|
)}
|
|
onClick={() => onSelect?.(member)}
|
|
>
|
|
{/* 顶部装饰条 */}
|
|
<div className={cn(
|
|
"absolute top-0 left-4 right-4 h-1 rounded-b-full",
|
|
isHighlighted ? "bg-gradient-to-r from-yellow-400 via-amber-500 to-yellow-400"
|
|
: isFounder || isRoot ? "bg-gradient-to-r from-amber-400 via-orange-500 to-amber-400"
|
|
: isSpouse ? "bg-gradient-to-r from-rose-300 via-pink-400 to-rose-300"
|
|
: isDead ? "bg-gradient-to-r from-stone-400 via-gray-500 to-stone-400"
|
|
: isMale ? "bg-gradient-to-r from-sky-400 via-blue-500 to-sky-400"
|
|
: "bg-gradient-to-r from-rose-400 via-pink-500 to-rose-400"
|
|
)} />
|
|
|
|
{/* 选中打钩标记 */}
|
|
{isSelected && (
|
|
<div className="absolute top-2 left-2 z-20">
|
|
<div className="w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center shadow-lg">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 世代标签 - 现代胶囊风格 */}
|
|
<div className={cn(
|
|
"absolute -top-2.5 px-3 py-0.5 text-[10px] font-medium tracking-wider rounded-full",
|
|
"shadow-md backdrop-blur-sm",
|
|
isFounder || isRoot
|
|
? "bg-gradient-to-r from-amber-500 to-orange-500 text-white"
|
|
: isDead
|
|
? "bg-gradient-to-r from-stone-500 to-gray-500 text-white"
|
|
: isMale
|
|
? "bg-gradient-to-r from-sky-500 to-blue-500 text-white"
|
|
: "bg-gradient-to-r from-rose-500 to-pink-500 text-white"
|
|
)}>
|
|
第{member.generation}世
|
|
</div>
|
|
|
|
{/* 头像 - 圆形带光晕效果 */}
|
|
<div className="relative mt-3">
|
|
{/* 光晕背景 */}
|
|
<div className={cn(
|
|
"absolute inset-0 rounded-full blur-md opacity-40",
|
|
isFounder || isRoot ? "bg-amber-400"
|
|
: isDead ? "bg-stone-400"
|
|
: isMale ? "bg-sky-400"
|
|
: "bg-rose-400"
|
|
)} />
|
|
<div className={cn(
|
|
"relative h-14 w-14 rounded-full flex items-center justify-center overflow-hidden",
|
|
"border-[3px] shadow-lg bg-white dark:bg-gray-800",
|
|
"ring-2 ring-white dark:ring-gray-700",
|
|
isFounder || isRoot
|
|
? "border-amber-400"
|
|
: isDead
|
|
? "border-stone-400"
|
|
: isMale
|
|
? "border-sky-400"
|
|
: "border-rose-400"
|
|
)}>
|
|
{avatarBlobUrl ? (
|
|
<img
|
|
src={avatarBlobUrl}
|
|
alt={member.fullName}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
member.gender === 'FEMALE' ? (
|
|
<CircleUserRound className={cn("h-8 w-8", isDead ? "text-stone-400" : "text-rose-500")} />
|
|
) : (
|
|
<CircleUser className={cn("h-8 w-8", isDead ? "text-stone-400" : "text-sky-500")} />
|
|
)
|
|
)}
|
|
</div>
|
|
{/* 在线/已故状态指示器 */}
|
|
<div className={cn(
|
|
"absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full border-2 border-white dark:border-gray-800",
|
|
isDead ? "bg-stone-400" : "bg-emerald-500"
|
|
)} />
|
|
</div>
|
|
|
|
{/* 姓名和信息 */}
|
|
<div className="text-center w-full mt-3 space-y-1">
|
|
<div className="font-semibold leading-tight text-[15px] tracking-wide text-black dark:text-white">
|
|
<MemberNameWithStatus
|
|
name={`${member.surname}${member.givenName}`}
|
|
isDead={isDead}
|
|
/>
|
|
</div>
|
|
{member.courtesyName && (
|
|
<div className="text-[11px] tracking-wide text-black dark:text-white">
|
|
字 {member.courtesyName}
|
|
</div>
|
|
)}
|
|
{lifeSpan && (
|
|
<div className={cn(
|
|
"text-[10px] font-mono tracking-wider px-2 py-0.5 rounded-full inline-block text-black dark:text-white",
|
|
isDead
|
|
? "bg-stone-200 dark:bg-stone-700"
|
|
: "bg-gray-200 dark:bg-gray-700"
|
|
)}>
|
|
{lifeSpan}
|
|
</div>
|
|
)}
|
|
{ageText && (
|
|
<div className="text-[10px] text-black dark:text-white">
|
|
{ageText}
|
|
</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 dark:bg-gray-800 shadow-lg transition-all duration-200",
|
|
"hover:scale-110 active:scale-95",
|
|
"border border-gray-200 dark:border-gray-700",
|
|
isCollapsed
|
|
? "text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/30"
|
|
: "text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
)}
|
|
title={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"
|
|
className={cn("transition-transform duration-200", isCollapsed ? "rotate-180" : "")}
|
|
>
|
|
<polyline points="18 15 12 9 6 15"></polyline>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
// 如果没有可添加的选项或在关系模式下,直接返回卡片
|
|
if (!hasAddOptions || relationMode) {
|
|
return cardContent
|
|
}
|
|
|
|
// 有可添加选项时,包裹右键菜单
|
|
return (
|
|
<>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger>
|
|
{cardContent}
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="w-48">
|
|
<ContextMenuLabel className="text-xs text-muted-foreground">
|
|
为 {member.fullName} 添加
|
|
</ContextMenuLabel>
|
|
<ContextMenuSeparator />
|
|
|
|
{canAddFather && (
|
|
<ContextMenuItem
|
|
className="gap-2 cursor-pointer"
|
|
onPointerDown={(e) => {
|
|
e.preventDefault()
|
|
handleAddMember('father')
|
|
}}
|
|
>
|
|
<UserPlus className="h-4 w-4 text-blue-500" />
|
|
添加父亲
|
|
</ContextMenuItem>
|
|
)}
|
|
|
|
{canAddMother && (
|
|
<ContextMenuItem
|
|
className="gap-2 cursor-pointer"
|
|
onPointerDown={(e) => {
|
|
e.preventDefault()
|
|
handleAddMember('mother')
|
|
}}
|
|
>
|
|
<UserPlus className="h-4 w-4 text-pink-500" />
|
|
添加母亲
|
|
</ContextMenuItem>
|
|
)}
|
|
|
|
{(canAddFather || canAddMother) && (canAddSpouse || canAddChild) && (
|
|
<ContextMenuSeparator />
|
|
)}
|
|
|
|
{canAddSpouse && (
|
|
<ContextMenuItem
|
|
className="gap-2 cursor-pointer"
|
|
onPointerDown={(e) => {
|
|
e.preventDefault()
|
|
if (onAddRelation) {
|
|
onAddRelation('spouse')
|
|
} else {
|
|
handleAddMember('spouse')
|
|
}
|
|
}}
|
|
>
|
|
<Users className="h-4 w-4 text-red-500" />
|
|
添加配偶
|
|
</ContextMenuItem>
|
|
)}
|
|
|
|
{canAddChild && (
|
|
<ContextMenuItem
|
|
className="gap-2 cursor-pointer"
|
|
onPointerDown={(e) => {
|
|
e.preventDefault()
|
|
if (onAddRelation) {
|
|
onAddRelation('child')
|
|
} else {
|
|
handleAddMember('child')
|
|
}
|
|
}}
|
|
>
|
|
<Baby className="h-4 w-4 text-green-500" />
|
|
添加子女
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
|
|
{/* 代数重新计算警告对话框 */}
|
|
{pendingRelationType && (
|
|
<GenerationWarningDialog
|
|
open={showWarningDialog}
|
|
onOpenChange={setShowWarningDialog}
|
|
memberName={member.fullName}
|
|
relationType={pendingRelationType}
|
|
onConfirm={handleConfirmAddParent}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function FamilyNode({
|
|
member,
|
|
spouse,
|
|
spouses,
|
|
isRoot,
|
|
onSelect,
|
|
isHighlighted,
|
|
highlightedMemberId,
|
|
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}
|
|
hasChildren={hasChildren}
|
|
isCollapsed={isCollapsed}
|
|
onToggleCollapse={onToggleCollapse}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// 有配偶时,合并到一个卡片中显示(类似D3布局)
|
|
const isMale = member.gender === 'MALE'
|
|
const isDead = !!member.deathDate
|
|
|
|
// 检查是否有任何配偶被高亮,如果是则整个卡片高亮
|
|
const isAnySpouseHighlighted = spouseList.some(sp => sp.id === highlightedMemberId)
|
|
const isCardHighlighted = isHighlighted || isAnySpouseHighlighted
|
|
|
|
// 使用 isRoot prop 而不是 isFounder 字段
|
|
const isFounder = isRoot === true
|
|
const avatarUrl = member.avatarUrl
|
|
|
|
// 计算生卒年份
|
|
const birthYear = formatYear(member.birthDate)
|
|
const deathYear = formatYear(member.deathDate)
|
|
const lifeSpan = birthYear ? `${birthYear}${deathYear ? ` — ${deathYear}` : ' —'}` : ''
|
|
const age = calculateAge(member.birthDate, member.deathDate)
|
|
const ageText = age !== null ? (isDead ? `享年${age}岁` : `${age}岁`) : ''
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative rounded-xl overflow-hidden transition-all duration-300 min-h-[177px] flex flex-col",
|
|
"shadow-[0_4px_20px_rgba(0,0,0,0.08)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.15)]",
|
|
"hover:scale-[1.02] hover:-translate-y-1",
|
|
isCardHighlighted
|
|
? "bg-gradient-to-br from-yellow-50 via-amber-50 to-yellow-100 ring-2 ring-yellow-400 shadow-[0_8px_30px_rgba(234,179,8,0.4)]"
|
|
: isFounder || isRoot
|
|
? "bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50"
|
|
: "bg-gradient-to-br from-slate-50 via-gray-50 to-slate-100"
|
|
)}
|
|
>
|
|
{/* 顶部装饰条 */}
|
|
<div className={cn(
|
|
"h-1.5",
|
|
isCardHighlighted ? "bg-gradient-to-r from-yellow-400 via-amber-500 to-yellow-400"
|
|
: isFounder || isRoot ? "bg-gradient-to-r from-amber-400 via-orange-500 to-amber-400"
|
|
: "bg-gradient-to-r from-slate-400 via-gray-500 to-slate-400"
|
|
)} />
|
|
|
|
{/* 世代标签 - 居中显示 */}
|
|
{member.generation && (
|
|
<div className="flex justify-center -mt-0.5 mb-1">
|
|
<span className={cn(
|
|
"text-[10px] font-medium px-2 py-0.5 rounded-full text-white",
|
|
isFounder || isRoot
|
|
? "bg-gradient-to-r from-amber-500 to-orange-500"
|
|
: "bg-gradient-to-r from-slate-500 to-gray-600"
|
|
)}>
|
|
第{member.generation}世
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 内容区域 */}
|
|
<div className="p-3 pt-1 flex gap-3 flex-1 items-center justify-center">
|
|
{/* 主成员 */}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col items-center cursor-pointer flex-1 min-w-[90px] relative",
|
|
relationMode && "hover:ring-2 hover:ring-primary/50 rounded-lg p-1"
|
|
)}
|
|
data-member-id={member.id}
|
|
onClick={() => onSelect?.(member)}
|
|
>
|
|
{/* 选中对钩标记 */}
|
|
{isSelected && (
|
|
<div className="absolute -top-1 -right-1 z-20 w-5 h-5 bg-primary rounded-full flex items-center justify-center shadow-md">
|
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{/* 头像 */}
|
|
<div className="relative mb-2">
|
|
<div className={cn(
|
|
"absolute inset-0 rounded-full blur-md opacity-60",
|
|
isFounder ? "bg-amber-300" : isDead ? "bg-stone-300" : isMale ? "bg-sky-300" : "bg-rose-300"
|
|
)} style={{ transform: 'scale(1.1)' }} />
|
|
<div className={cn(
|
|
"relative w-12 h-12 rounded-full border-2 overflow-hidden bg-white flex items-center justify-center",
|
|
isFounder ? "border-amber-500" : isDead ? "border-stone-400" : isMale ? "border-sky-400" : "border-rose-400",
|
|
isSelected && "ring-2 ring-primary ring-offset-2"
|
|
)}>
|
|
{avatarUrl ? (
|
|
<img src={avatarUrl} alt={member.fullName} className="w-full h-full object-cover" />
|
|
) : isMale ? (
|
|
<CircleUser className={cn("w-7 h-7", isDead ? "text-stone-400" : "text-sky-500")} />
|
|
) : (
|
|
<CircleUserRound className={cn("w-7 h-7", isDead ? "text-stone-400" : "text-rose-500")} />
|
|
)}
|
|
</div>
|
|
{/* 状态指示器 */}
|
|
<div className={cn(
|
|
"absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white",
|
|
isDead ? "bg-stone-400" : "bg-emerald-500"
|
|
)} />
|
|
</div>
|
|
|
|
{/* 姓名 */}
|
|
<div className="text-center">
|
|
<div className="font-semibold text-[13px] text-black leading-tight">
|
|
<MemberNameWithStatus name={member.fullName} isDead={isDead} />
|
|
</div>
|
|
{lifeSpan && (
|
|
<div className="text-[9px] text-black mt-0.5 bg-gray-200/80 px-1.5 py-0.5 rounded-full">
|
|
{lifeSpan}
|
|
</div>
|
|
)}
|
|
{ageText && (
|
|
<div className="text-[9px] text-black mt-0.5">
|
|
{ageText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 配偶们 */}
|
|
{spouseList.map((sp, idx) => {
|
|
const spIsMale = sp.gender === 'MALE'
|
|
const spIsDead = !!sp.deathDate
|
|
const spAvatarUrl = sp.avatarUrl
|
|
const spBirthYear = formatYear(sp.birthDate)
|
|
const spDeathYear = formatYear(sp.deathDate)
|
|
const spLifeSpan = spBirthYear ? `${spBirthYear}${spDeathYear ? `-${spDeathYear}` : '-'}` : ''
|
|
const spAge = calculateAge(sp.birthDate, sp.deathDate)
|
|
const spAgeText = spAge !== null ? (spIsDead ? `享年${spAge}岁` : `${spAge}岁`) : ''
|
|
const spIsSelected = selectedMembers.includes(sp.id)
|
|
|
|
return (
|
|
<div key={sp.id} className="flex items-center gap-2">
|
|
{/* 连接线 */}
|
|
{idx === 0 && (
|
|
<div className="flex flex-col items-center">
|
|
<Heart className="h-3 w-3 text-rose-400" />
|
|
</div>
|
|
)}
|
|
|
|
{/* 配偶卡片 */}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col items-center cursor-pointer min-w-[80px] relative p-2 rounded-lg transition-all",
|
|
relationMode && "hover:ring-2 hover:ring-primary/50"
|
|
)}
|
|
data-member-id={sp.id}
|
|
onClick={() => onSelect?.(sp)}
|
|
>
|
|
{/* 选中对钩标记 */}
|
|
{spIsSelected && (
|
|
<div className="absolute -top-1 -right-1 z-20 w-5 h-5 bg-primary rounded-full flex items-center justify-center shadow-md">
|
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{/* 头像 */}
|
|
<div className="relative mb-2">
|
|
<div className={cn(
|
|
"absolute inset-0 rounded-full blur-md opacity-50",
|
|
spIsDead ? "bg-stone-300" : spIsMale ? "bg-sky-300" : "bg-rose-300"
|
|
)} style={{ transform: 'scale(1.1)' }} />
|
|
<div className={cn(
|
|
"relative w-10 h-10 rounded-full border-2 overflow-hidden bg-white flex items-center justify-center",
|
|
spIsDead ? "border-stone-400" : spIsMale ? "border-sky-400" : "border-rose-400",
|
|
spIsSelected && "ring-2 ring-primary ring-offset-2"
|
|
)}>
|
|
{spAvatarUrl ? (
|
|
<img src={spAvatarUrl} alt={sp.fullName} className="w-full h-full object-cover" />
|
|
) : spIsMale ? (
|
|
<CircleUser className={cn("w-6 h-6", spIsDead ? "text-stone-400" : "text-sky-500")} />
|
|
) : (
|
|
<CircleUserRound className={cn("w-6 h-6", spIsDead ? "text-stone-400" : "text-rose-500")} />
|
|
)}
|
|
</div>
|
|
{/* 状态指示器 */}
|
|
<div className={cn(
|
|
"absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-white",
|
|
spIsDead ? "bg-stone-400" : "bg-emerald-500"
|
|
)} />
|
|
</div>
|
|
|
|
{/* 姓名 */}
|
|
<div className="text-center">
|
|
<div className="font-semibold text-[11px] text-black leading-tight">
|
|
<MemberNameWithStatus name={sp.fullName} isDead={spIsDead} />
|
|
</div>
|
|
{spLifeSpan && (
|
|
<div className="text-[8px] text-black mt-0.5 bg-gray-200/80 px-1 py-0.5 rounded-full">
|
|
{spLifeSpan}
|
|
</div>
|
|
)}
|
|
{spAgeText && (
|
|
<div className="text-[8px] text-black mt-0.5">
|
|
{spAgeText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</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 border-gray-200 shadow-md",
|
|
"hover:bg-gray-50 hover:scale-110 transition-all",
|
|
"text-gray-600 text-xs font-medium"
|
|
)}
|
|
>
|
|
{isCollapsed ? '▼' : '▲'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|