This commit is contained in:
freedakgmail
2025-11-30 20:00:18 +08:00
parent 86e889cc2a
commit 0326894b09
6 changed files with 316 additions and 52 deletions
+27 -5
View File
@@ -80,6 +80,11 @@ const MemberCard = memo(function MemberCard({
member.spouseIds && member.spouseIds.length > 0 &&
!isFounder
// 判断是否是孤立成员(没有父母、没有配偶、不是始祖)
const isOrphan = !member.fatherId && !member.motherId &&
(!member.spouseIds || member.spouseIds.length === 0) &&
!isFounder
// 获取配偶信息
const spouse = isSpouse && 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 cardStyle = isFounder
? '!border-2 !border-amber-400 !bg-amber-50/30 dark:!bg-amber-900/20 shadow-md' // 始祖:金色边框(优先级最高)
: 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' // 在世:浅金色边框
: isOrphan
? '!border-2 !border-orange-400 !bg-orange-50/30 dark:!bg-orange-900/20' // 孤立:橙色边框提醒
: 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 (
<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>
) : 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 ? (
<p className="text-xs text-purple-700 mt-0.5 truncate">
{spouseName}
@@ -369,13 +381,23 @@ export default function MembersPage() {
.sort((a, b) => a[0] - b[0])
.map(([generation, members]) => {
// 区分家族成员和配偶
// 配偶定义:没有父母但有配偶的成员(嫁入/娶入的成员)
// 其他所有成员都应该显示,包括孤立成员
const familyMembers = members.filter(m => {
const isFounder = m.isFounder === true
const hasFamilyParents = m.fatherId || m.motherId
const hasSpouse = m.spouseIds && m.spouseIds.length > 0
// 始祖或有父母的成员:显示
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
})
+6 -40
View File
@@ -136,23 +136,6 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
const handleChange = (field: keyof FamilyMember, value: any) => {
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) => {
const newData = { ...prev, [field]: value }
@@ -246,20 +229,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
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)
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 = {
id: formData.id || generateUUID(),
surname: formData.surname || "",
@@ -529,15 +502,8 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
<SelectItem value="none"></SelectItem>
{potentialRelatives
.filter((m) => {
if (m.id === formData.motherId) {
console.log('Mother already selected, showing:', { id: m.id, name: m.fullName })
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
if (m.id === formData.motherId) return true
return m.gender === "FEMALE" && m.generation === (formData.generation || 1) - 1
})
.map((m) => (
<SelectItem key={m.id} value={m.id}>
+216
View File
@@ -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>
)
}
+17 -6
View File
@@ -26,6 +26,7 @@ interface FamilyNodeProps {
relationMode?: boolean
isSelected?: boolean
canEdit?: boolean
onAddRelation?: (type: "child" | "spouse") => void
}
// 格式化日期显示(只显示年份)
@@ -61,7 +62,8 @@ function PersonCard({
isHighlighted,
relationMode,
isSelected,
canEdit = true
canEdit = true,
onAddRelation
}: {
member: FamilyMember
isRoot?: boolean
@@ -71,6 +73,7 @@ function PersonCard({
relationMode?: boolean
isSelected?: boolean
canEdit?: boolean
onAddRelation?: (type: "child" | "spouse") => void
}) {
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarUrl)
const router = useRouter()
@@ -294,7 +297,11 @@ function PersonCard({
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('spouse')
if (onAddRelation) {
onAddRelation('spouse')
} else {
handleAddMember('spouse')
}
}}
>
<Users className="h-4 w-4 text-red-500" />
@@ -307,7 +314,11 @@ function PersonCard({
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('child')
if (onAddRelation) {
onAddRelation('child')
} else {
handleAddMember('child')
}
}}
>
<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
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} />
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} relationMode={relationMode} isSelected={isSelected} canEdit={canEdit} onAddRelation={onAddRelation} />
}
// 有配偶时,并排显示所有配偶
return (
<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) => (
<div key={spouseMember.id} className="flex items-center gap-2">
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
+43
View File
@@ -1,6 +1,7 @@
"use client"
import type { FamilyMember } from "@/types/family"
import { FamilyNode } from "./family-node"
import { AddRelationDialog } from "./add-relation-dialog"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
import { useRouter } from "next/navigation"
@@ -36,6 +37,7 @@ export function TreeLayout({
const containerRef = useRef<HTMLDivElement>(null)
const [connections, setConnections] = useState<Connection[]>([])
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
@@ -161,6 +163,34 @@ export function TreeLayout({
if (!root) return null
// 递归渲染树节点 - 使用 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 member = getMember(memberId)
if (!member) return null
@@ -197,6 +227,7 @@ export function TreeLayout({
relationMode={relationMode}
isSelected={selectedMembers.includes(member.id)}
canEdit={canEdit}
onAddRelation={(type) => setRelationDialog({ member, type })}
/>
</div>
@@ -256,6 +287,18 @@ export function TreeLayout({
<TreeNode memberId={rootId} isRoot={true} />
</div>
</div>
{relationDialog && (
<AddRelationDialog
open={!!relationDialog}
onOpenChange={(open) => {
if (!open) setRelationDialog(null)
}}
member={relationDialog.member}
relationType={relationDialog.type}
onCreateNew={handleCreateNewRelation}
/>
)}
</div>
)
}
+7 -1
View File
@@ -224,10 +224,16 @@ function FamilyProviderInner({ children }: { children: React.ReactNode }) {
const updateMember = useCallback(async (id: string, updates: Partial<FamilyMember>) => {
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}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
body: JSON.stringify(payload),
})
if (!response.ok) {