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
+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>
)
}