0.3.1.3
This commit is contained in:
+27
-5
@@ -80,6 +80,11 @@ const MemberCard = memo(function MemberCard({
|
|||||||
member.spouseIds && member.spouseIds.length > 0 &&
|
member.spouseIds && member.spouseIds.length > 0 &&
|
||||||
!isFounder
|
!isFounder
|
||||||
|
|
||||||
|
// 判断是否是孤立成员(没有父母、没有配偶、不是始祖)
|
||||||
|
const isOrphan = !member.fatherId && !member.motherId &&
|
||||||
|
(!member.spouseIds || member.spouseIds.length === 0) &&
|
||||||
|
!isFounder
|
||||||
|
|
||||||
// 获取配偶信息
|
// 获取配偶信息
|
||||||
const spouse = isSpouse && member.spouseIds?.[0]
|
const spouse = isSpouse && member.spouseIds?.[0]
|
||||||
? allMembers.find(m => m.id === member.spouseIds[0])
|
? allMembers.find(m => m.id === member.spouseIds[0])
|
||||||
@@ -94,12 +99,14 @@ const MemberCard = memo(function MemberCard({
|
|||||||
const isDead = !!member.deathDate
|
const isDead = !!member.deathDate
|
||||||
|
|
||||||
// 根据在世/已故状态设置卡片样式
|
// 根据在世/已故状态设置卡片样式
|
||||||
// 优先级:始祖 > 已故 > 在世
|
// 优先级:始祖 > 孤立 > 已故 > 在世
|
||||||
const cardStyle = isFounder
|
const cardStyle = isFounder
|
||||||
? '!border-2 !border-amber-400 !bg-amber-50/30 dark:!bg-amber-900/20 shadow-md' // 始祖:金色边框(优先级最高)
|
? '!border-2 !border-amber-400 !bg-amber-50/30 dark:!bg-amber-900/20 shadow-md' // 始祖:金色边框(优先级最高)
|
||||||
: isDead
|
: isOrphan
|
||||||
? '!border-2 !border-gray-400 dark:!border-gray-600 !bg-gray-50/50 dark:!bg-gray-900/30' // 已故:灰色深边框
|
? '!border-2 !border-orange-400 !bg-orange-50/30 dark:!bg-orange-900/20' // 孤立:橙色边框提醒
|
||||||
: '!border-2 !border-amber-300/70 !bg-card dark:!border-amber-600/50' // 在世:浅金色边框
|
: isDead
|
||||||
|
? '!border-2 !border-gray-400 dark:!border-gray-600 !bg-gray-50/50 dark:!bg-gray-900/30' // 已故:灰色深边框
|
||||||
|
: '!border-2 !border-amber-300/70 !bg-card dark:!border-amber-600/50' // 在世:浅金色边框
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -173,6 +180,11 @@ const MemberCard = memo(function MemberCard({
|
|||||||
<p className="text-xs text-amber-700 font-semibold mt-0.5 truncate">
|
<p className="text-xs text-amber-700 font-semibold mt-0.5 truncate">
|
||||||
家族始祖
|
家族始祖
|
||||||
</p>
|
</p>
|
||||||
|
) : isOrphan ? (
|
||||||
|
<p className="text-xs text-orange-600 font-medium mt-0.5 truncate flex items-center gap-1">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>孤立成员 - 请关联至族谱</span>
|
||||||
|
</p>
|
||||||
) : isSpouse && spouseName ? (
|
) : isSpouse && spouseName ? (
|
||||||
<p className="text-xs text-purple-700 mt-0.5 truncate">
|
<p className="text-xs text-purple-700 mt-0.5 truncate">
|
||||||
{spouseName}的配偶
|
{spouseName}的配偶
|
||||||
@@ -369,13 +381,23 @@ export default function MembersPage() {
|
|||||||
.sort((a, b) => a[0] - b[0])
|
.sort((a, b) => a[0] - b[0])
|
||||||
.map(([generation, members]) => {
|
.map(([generation, members]) => {
|
||||||
// 区分家族成员和配偶
|
// 区分家族成员和配偶
|
||||||
|
// 配偶定义:没有父母但有配偶的成员(嫁入/娶入的成员)
|
||||||
|
// 其他所有成员都应该显示,包括孤立成员
|
||||||
const familyMembers = members.filter(m => {
|
const familyMembers = members.filter(m => {
|
||||||
const isFounder = m.isFounder === true
|
const isFounder = m.isFounder === true
|
||||||
const hasFamilyParents = m.fatherId || m.motherId
|
const hasFamilyParents = m.fatherId || m.motherId
|
||||||
|
const hasSpouse = m.spouseIds && m.spouseIds.length > 0
|
||||||
|
|
||||||
|
// 始祖或有父母的成员:显示
|
||||||
if (hasFamilyParents || isFounder) return true
|
if (hasFamilyParents || isFounder) return true
|
||||||
if (generation === 1 && (!m.spouseIds || m.spouseIds.length === 0)) return true
|
|
||||||
|
|
||||||
|
// 第一代无配偶成员:显示
|
||||||
|
if (generation === 1 && !hasSpouse) return true
|
||||||
|
|
||||||
|
// 孤立成员(无父母、无配偶、非始祖):也要显示,方便用户关联
|
||||||
|
if (!hasFamilyParents && !hasSpouse && !isFounder) return true
|
||||||
|
|
||||||
|
// 配偶(无父母但有配偶):不在这里显示,会跟随主成员显示
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -136,23 +136,6 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
|||||||
const handleChange = (field: keyof FamilyMember, value: any) => {
|
const handleChange = (field: keyof FamilyMember, value: any) => {
|
||||||
console.log('handleChange:', field, value)
|
console.log('handleChange:', field, value)
|
||||||
|
|
||||||
// 防御性检查:防止 Select 组件意外清空 initialData 中已有的值
|
|
||||||
// 这可能是由于 Select 组件在某些情况下(如选项尚未完全渲染)触发了无效的 onChange
|
|
||||||
if ((field === 'motherId' || field === 'fatherId') && !value) {
|
|
||||||
const initialValue = initialData?.[field]
|
|
||||||
const currentValue = formData[field]
|
|
||||||
// 只有当当前值等于初始值且尝试清空时才阻止
|
|
||||||
if (initialValue && currentValue === initialValue) {
|
|
||||||
console.warn(`Prevented accidental clearing of ${field}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 如果当前值不为空,但尝试清空为空字符串,也阻止(因为Select组件可能发送空字符串而不是undefined)
|
|
||||||
if (currentValue && value === '') {
|
|
||||||
console.warn(`Prevented clearing of ${field} to empty string`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newData = { ...prev, [field]: value }
|
const newData = { ...prev, [field]: value }
|
||||||
|
|
||||||
@@ -246,20 +229,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
|||||||
newErrors.push("请填写正确的世系")
|
newErrors.push("请填写正确的世系")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关系验证:非始祖必须与家族建立联系
|
// 注:允许孤立成员存在,可以后续通过族谱关联功能建立关系
|
||||||
if (!isFounder) {
|
// 这样可以修正错误的关系设置
|
||||||
// 1. 检查是否有主要成员的父母
|
|
||||||
const hasValidFather = formData.fatherId && existingMembers.some(m => m.id === formData.fatherId)
|
|
||||||
const hasValidMother = formData.motherId && existingMembers.some(m => m.id === formData.motherId)
|
|
||||||
|
|
||||||
// 2. 检查是否有主要成员的配偶
|
|
||||||
const hasValidSpouse = formData.spouseIds && formData.spouseIds.length > 0 &&
|
|
||||||
formData.spouseIds.some(id => existingMembers.some(m => m.id === id))
|
|
||||||
|
|
||||||
if (!hasValidFather && !hasValidMother && !hasValidSpouse) {
|
|
||||||
newErrors.push("孤立成员:请选择家族成员作为父母,或添加家族成员为配偶")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors)
|
setErrors(newErrors)
|
||||||
return newErrors.length === 0
|
return newErrors.length === 0
|
||||||
@@ -273,6 +244,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确保所有必填字段都有值
|
// 确保所有必填字段都有值
|
||||||
|
// 注意:fatherId/motherId 需要显式设为 null 而不是 undefined,
|
||||||
|
// 否则 JSON.stringify 会忽略 undefined 值,导致后端收不到更新
|
||||||
const memberData: FamilyMember = {
|
const memberData: FamilyMember = {
|
||||||
id: formData.id || generateUUID(),
|
id: formData.id || generateUUID(),
|
||||||
surname: formData.surname || "",
|
surname: formData.surname || "",
|
||||||
@@ -529,15 +502,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
|||||||
<SelectItem value="none">无</SelectItem>
|
<SelectItem value="none">无</SelectItem>
|
||||||
{potentialRelatives
|
{potentialRelatives
|
||||||
.filter((m) => {
|
.filter((m) => {
|
||||||
if (m.id === formData.motherId) {
|
if (m.id === formData.motherId) return true
|
||||||
console.log('Mother already selected, showing:', { id: m.id, name: m.fullName })
|
return m.gender === "FEMALE" && m.generation === (formData.generation || 1) - 1
|
||||||
return true
|
|
||||||
}
|
|
||||||
const isMatch = m.gender === "FEMALE" && m.generation === (formData.generation || 1) - 1
|
|
||||||
if (isMatch) {
|
|
||||||
console.log('Mother match found:', { id: m.id, name: m.fullName, gender: m.gender, generation: m.generation })
|
|
||||||
}
|
|
||||||
return isMatch
|
|
||||||
})
|
})
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<SelectItem key={m.id} value={m.id}>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState, useEffect } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { MemberNameWithStatus } from "@/components/member-name-with-status"
|
||||||
|
import { useFamily } from "@/context/family-context"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import type { FamilyMember } from "@/types/family"
|
||||||
|
|
||||||
|
interface AddRelationDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
member: FamilyMember
|
||||||
|
relationType: "child" | "spouse"
|
||||||
|
onCreateNew: (prefillName: string, relationType: "child" | "spouse") => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CandidateMeta {
|
||||||
|
member: FamilyMember
|
||||||
|
fatherName?: string
|
||||||
|
motherName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddRelationDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
member,
|
||||||
|
relationType,
|
||||||
|
onCreateNew,
|
||||||
|
}: AddRelationDialogProps) {
|
||||||
|
const { treeData, updateMember } = useFamily()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [linkingId, setLinkingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery("")
|
||||||
|
setLinkingId(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
|
||||||
|
const candidates: CandidateMeta[] = useMemo(() => {
|
||||||
|
if (!normalizedQuery) return []
|
||||||
|
|
||||||
|
const members = Object.values(treeData.members)
|
||||||
|
return members
|
||||||
|
.filter((candidate) => {
|
||||||
|
if (candidate.id === member.id) return false
|
||||||
|
if (!candidate.fullName.toLowerCase().includes(normalizedQuery) &&
|
||||||
|
!(candidate.givenName || "").toLowerCase().includes(normalizedQuery) &&
|
||||||
|
!(candidate.courtesyName || "").toLowerCase().includes(normalizedQuery)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationType === "child") {
|
||||||
|
if (candidate.generation !== (member.generation + 1)) return false
|
||||||
|
if (member.childrenIds?.includes(candidate.id)) return false
|
||||||
|
if (member.spouseIds?.includes(candidate.id)) return false
|
||||||
|
|
||||||
|
const fatherMatches = candidate.fatherId ? candidate.fatherId === member.id : true
|
||||||
|
const motherMatches = candidate.motherId ? candidate.motherId === member.id : true
|
||||||
|
|
||||||
|
if (member.gender === "MALE" && !fatherMatches) return false
|
||||||
|
if (member.gender === "FEMALE" && !motherMatches) return false
|
||||||
|
if (member.gender !== "MALE" && member.gender !== "FEMALE") {
|
||||||
|
// 非男女成员,允许填补任一缺失的父母
|
||||||
|
if (candidate.fatherId && candidate.motherId) return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((candidate) => ({
|
||||||
|
member: candidate,
|
||||||
|
fatherName: candidate.fatherId ? treeData.members[candidate.fatherId]?.fullName : undefined,
|
||||||
|
motherName: candidate.motherId ? treeData.members[candidate.motherId]?.fullName : undefined,
|
||||||
|
}))
|
||||||
|
}, [member, relationType, treeData.members, normalizedQuery])
|
||||||
|
|
||||||
|
const handleLink = async (target: FamilyMember) => {
|
||||||
|
try {
|
||||||
|
setLinkingId(target.id)
|
||||||
|
if (relationType === "child") {
|
||||||
|
const payload: Partial<FamilyMember> = {}
|
||||||
|
if (member.gender === "FEMALE") {
|
||||||
|
payload.motherId = member.id
|
||||||
|
} else if (member.gender === "MALE") {
|
||||||
|
payload.fatherId = member.id
|
||||||
|
} else {
|
||||||
|
if (!target.fatherId) {
|
||||||
|
payload.fatherId = member.id
|
||||||
|
} else if (!target.motherId) {
|
||||||
|
payload.motherId = member.id
|
||||||
|
} else {
|
||||||
|
throw new Error("该成员已绑定父母")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await updateMember(target.id, payload)
|
||||||
|
toast({
|
||||||
|
title: "关联成功",
|
||||||
|
description: `已将 ${target.fullName} 标记为 ${member.fullName} 的子女。`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const existing = new Set(member.spouseIds || [])
|
||||||
|
existing.add(target.id)
|
||||||
|
await updateMember(member.id, { spouseIds: Array.from(existing) })
|
||||||
|
toast({
|
||||||
|
title: "关联成功",
|
||||||
|
description: `${member.fullName} 与 ${target.fullName} 已建立配偶关系。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "关联失败"
|
||||||
|
toast({
|
||||||
|
title: "操作失败",
|
||||||
|
description: message,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLinkingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
if (!normalizedQuery) return
|
||||||
|
onCreateNew(query.trim(), relationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加{relationType === "child" ? "子女" : "配偶"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
先输入姓名,系统会检索已有成员。若没有匹配,可直接新建。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relation-name">成员姓名</Label>
|
||||||
|
<Input
|
||||||
|
id="relation-name"
|
||||||
|
placeholder="请输入姓名或字号"
|
||||||
|
value={query}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{normalizedQuery ? (
|
||||||
|
candidates.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">找到以下可能的匹配:</p>
|
||||||
|
<ScrollArea className="max-h-64 rounded-md border">
|
||||||
|
<div className="divide-y">
|
||||||
|
{candidates.map(({ member: candidate, fatherName, motherName }) => (
|
||||||
|
<div key={candidate.id} className="p-3 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<MemberNameWithStatus name={candidate.fullName} isDead={!!candidate.deathDate} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
第 {candidate.generation} 世 · {candidate.gender === "FEMALE" ? "女" : "男"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleLink(candidate)}
|
||||||
|
disabled={linkingId === candidate.id}
|
||||||
|
>
|
||||||
|
{linkingId === candidate.id ? "关联中..." : "关联"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<div>父亲:{fatherName || "未登记"}</div>
|
||||||
|
<div>母亲:{motherName || "未登记"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">未找到匹配成员,可直接创建新成员。</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">请输入姓名以搜索已有成员。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-between">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="sm:flex-1">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateNew} disabled={!normalizedQuery} className="sm:flex-1">
|
||||||
|
新建“{query || "成员"}”
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ interface FamilyNodeProps {
|
|||||||
relationMode?: boolean
|
relationMode?: boolean
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
|
onAddRelation?: (type: "child" | "spouse") => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期显示(只显示年份)
|
// 格式化日期显示(只显示年份)
|
||||||
@@ -61,7 +62,8 @@ function PersonCard({
|
|||||||
isHighlighted,
|
isHighlighted,
|
||||||
relationMode,
|
relationMode,
|
||||||
isSelected,
|
isSelected,
|
||||||
canEdit = true
|
canEdit = true,
|
||||||
|
onAddRelation
|
||||||
}: {
|
}: {
|
||||||
member: FamilyMember
|
member: FamilyMember
|
||||||
isRoot?: boolean
|
isRoot?: boolean
|
||||||
@@ -71,6 +73,7 @@ function PersonCard({
|
|||||||
relationMode?: boolean
|
relationMode?: boolean
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
|
onAddRelation?: (type: "child" | "spouse") => void
|
||||||
}) {
|
}) {
|
||||||
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarUrl)
|
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarUrl)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -294,7 +297,11 @@ function PersonCard({
|
|||||||
className="gap-2 cursor-pointer"
|
className="gap-2 cursor-pointer"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleAddMember('spouse')
|
if (onAddRelation) {
|
||||||
|
onAddRelation('spouse')
|
||||||
|
} else {
|
||||||
|
handleAddMember('spouse')
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4 text-red-500" />
|
<Users className="h-4 w-4 text-red-500" />
|
||||||
@@ -307,7 +314,11 @@ function PersonCard({
|
|||||||
className="gap-2 cursor-pointer"
|
className="gap-2 cursor-pointer"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleAddMember('child')
|
if (onAddRelation) {
|
||||||
|
onAddRelation('child')
|
||||||
|
} else {
|
||||||
|
handleAddMember('child')
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Baby className="h-4 w-4 text-green-500" />
|
<Baby className="h-4 w-4 text-green-500" />
|
||||||
@@ -319,18 +330,18 @@ function PersonCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FamilyNode({ member, spouse, spouses, isRoot, onSelect, isHighlighted, relationMode, isSelected, canEdit = true }: FamilyNodeProps) {
|
export function FamilyNode({ member, spouse, spouses, isRoot, onSelect, isHighlighted, relationMode, isSelected, canEdit = true, onAddRelation }: FamilyNodeProps) {
|
||||||
// 优先使用 spouses 数组,如果没有则使用单个 spouse
|
// 优先使用 spouses 数组,如果没有则使用单个 spouse
|
||||||
const spouseList = spouses || (spouse ? [spouse] : [])
|
const spouseList = spouses || (spouse ? [spouse] : [])
|
||||||
|
|
||||||
if (spouseList.length === 0) {
|
if (spouseList.length === 0) {
|
||||||
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} />
|
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} onAddRelation={onAddRelation} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有配偶时,并排显示所有配偶
|
// 有配偶时,并排显示所有配偶
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} />
|
<PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} onAddRelation={onAddRelation} />
|
||||||
{spouseList.map((spouseMember, index) => (
|
{spouseList.map((spouseMember, index) => (
|
||||||
<div key={spouseMember.id} className="flex items-center gap-2">
|
<div key={spouseMember.id} className="flex items-center gap-2">
|
||||||
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
|
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import type { FamilyMember } from "@/types/family"
|
import type { FamilyMember } from "@/types/family"
|
||||||
import { FamilyNode } from "./family-node"
|
import { FamilyNode } from "./family-node"
|
||||||
|
import { AddRelationDialog } from "./add-relation-dialog"
|
||||||
import { useFamily } from "@/context/family-context"
|
import { useFamily } from "@/context/family-context"
|
||||||
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
|
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
@@ -36,6 +37,7 @@ export function TreeLayout({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [connections, setConnections] = useState<Connection[]>([])
|
const [connections, setConnections] = useState<Connection[]>([])
|
||||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
|
const [relationDialog, setRelationDialog] = useState<{ member: FamilyMember; type: "child" | "spouse" } | null>(null)
|
||||||
|
|
||||||
// 节点间距配置
|
// 节点间距配置
|
||||||
const HORIZONTAL_GAP = 32 // 2rem = 32px
|
const HORIZONTAL_GAP = 32 // 2rem = 32px
|
||||||
@@ -161,6 +163,34 @@ export function TreeLayout({
|
|||||||
if (!root) return null
|
if (!root) return null
|
||||||
|
|
||||||
// 递归渲染树节点 - 使用 memo 优化
|
// 递归渲染树节点 - 使用 memo 优化
|
||||||
|
const handleCreateNewRelation = (prefillName: string, relationType: "child" | "spouse") => {
|
||||||
|
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)
|
||||||
|
if (relationType === "child") {
|
||||||
|
if (relationDialog.member.gender === "MALE") {
|
||||||
|
params.set("fatherId", relationDialog.member.id)
|
||||||
|
if (relationDialog.member.spouseIds?.length) {
|
||||||
|
params.set("motherId", relationDialog.member.spouseIds[0])
|
||||||
|
}
|
||||||
|
} else if (relationDialog.member.gender === "FEMALE") {
|
||||||
|
params.set("motherId", relationDialog.member.id)
|
||||||
|
if (relationDialog.member.spouseIds?.length) {
|
||||||
|
params.set("fatherId", relationDialog.member.spouseIds[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.set("spouseId", relationDialog.member.id)
|
||||||
|
params.set("gender", relationDialog.member.gender === "MALE" ? "FEMALE" : "MALE")
|
||||||
|
}
|
||||||
|
const treeParams = new URLSearchParams(window.location.search)
|
||||||
|
treeParams.forEach((value, key) => {
|
||||||
|
params.set(key, value)
|
||||||
|
})
|
||||||
|
window.location.href = `/members/new?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
const TreeNode = memo(function TreeNode({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) {
|
const TreeNode = memo(function TreeNode({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) {
|
||||||
const member = getMember(memberId)
|
const member = getMember(memberId)
|
||||||
if (!member) return null
|
if (!member) return null
|
||||||
@@ -197,6 +227,7 @@ export function TreeLayout({
|
|||||||
relationMode={relationMode}
|
relationMode={relationMode}
|
||||||
isSelected={selectedMembers.includes(member.id)}
|
isSelected={selectedMembers.includes(member.id)}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
onAddRelation={(type) => setRelationDialog({ member, type })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,6 +287,18 @@ export function TreeLayout({
|
|||||||
<TreeNode memberId={rootId} isRoot={true} />
|
<TreeNode memberId={rootId} isRoot={true} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{relationDialog && (
|
||||||
|
<AddRelationDialog
|
||||||
|
open={!!relationDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRelationDialog(null)
|
||||||
|
}}
|
||||||
|
member={relationDialog.member}
|
||||||
|
relationType={relationDialog.type}
|
||||||
|
onCreateNew={handleCreateNewRelation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,10 +224,16 @@ function FamilyProviderInner({ children }: { children: React.ReactNode }) {
|
|||||||
const updateMember = useCallback(async (id: string, updates: Partial<FamilyMember>) => {
|
const updateMember = useCallback(async (id: string, updates: Partial<FamilyMember>) => {
|
||||||
if (!currentTree?.id) throw new Error('没有选择家族树')
|
if (!currentTree?.id) throw new Error('没有选择家族树')
|
||||||
|
|
||||||
|
// 将 undefined 转为 null,确保 JSON 序列化时能正确传递给后端
|
||||||
|
// 否则 JSON.stringify 会忽略 undefined 值,导致后端收不到更新
|
||||||
|
const payload = Object.fromEntries(
|
||||||
|
Object.entries(updates).map(([key, value]) => [key, value === undefined ? null : value])
|
||||||
|
)
|
||||||
|
|
||||||
const response = await fetch(`/api/trees/${currentTree.id}/members/${id}`, {
|
const response = await fetch(`/api/trees/${currentTree.id}/members/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user