diff --git a/app/members/page.tsx b/app/members/page.tsx index d23e2ae5..4fd64248 100644 --- a/app/members/page.tsx +++ b/app/members/page.tsx @@ -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 (
@@ -173,6 +180,11 @@ const MemberCard = memo(function MemberCard({

家族始祖

+ ) : isOrphan ? ( +

+ ⚠️ + 孤立成员 - 请关联至族谱 +

) : isSpouse && spouseName ? (

{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 }) diff --git a/components/members/member-form.tsx b/components/members/member-form.tsx index 06ef7c13..59410122 100644 --- a/components/members/member-form.tsx +++ b/components/members/member-form.tsx @@ -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 {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) => ( diff --git a/components/tree/add-relation-dialog.tsx b/components/tree/add-relation-dialog.tsx new file mode 100644 index 00000000..b60838f1 --- /dev/null +++ b/components/tree/add-relation-dialog.tsx @@ -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(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 = {} + 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 ( +

+ + + 添加{relationType === "child" ? "子女" : "配偶"} + + 先输入姓名,系统会检索已有成员。若没有匹配,可直接新建。 + + + +
+
+ + setQuery(e.target.value)} + /> +
+ + {normalizedQuery ? ( + candidates.length > 0 ? ( +
+

找到以下可能的匹配:

+ +
+ {candidates.map(({ member: candidate, fatherName, motherName }) => ( +
+
+
+ +

+ 第 {candidate.generation} 世 · {candidate.gender === "FEMALE" ? "女" : "男"} +

+
+ +
+
+
父亲:{fatherName || "未登记"}
+
母亲:{motherName || "未登记"}
+
+
+ ))} +
+
+
+ ) : ( +

未找到匹配成员,可直接创建新成员。

+ ) + ) : ( +

请输入姓名以搜索已有成员。

+ )} +
+ + + + + +
+
+ ) +} diff --git a/components/tree/family-node.tsx b/components/tree/family-node.tsx index 0b5b0fba..a2ae38f5 100644 --- a/components/tree/family-node.tsx +++ b/components/tree/family-node.tsx @@ -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') + } }} > @@ -307,7 +314,11 @@ function PersonCard({ className="gap-2 cursor-pointer" onPointerDown={(e) => { e.preventDefault() - handleAddMember('child') + if (onAddRelation) { + onAddRelation('child') + } else { + handleAddMember('child') + } }} > @@ -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 + return } // 有配偶时,并排显示所有配偶 return (
- + {spouseList.map((spouseMember, index) => (
diff --git a/components/tree/tree-layout.tsx b/components/tree/tree-layout.tsx index 263a4f7e..d4015cac 100644 --- a/components/tree/tree-layout.tsx +++ b/components/tree/tree-layout.tsx @@ -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(null) const [connections, setConnections] = useState([]) const nodeRefs = useRef>(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 })} />
@@ -256,6 +287,18 @@ export function TreeLayout({
+ + {relationDialog && ( + { + if (!open) setRelationDialog(null) + }} + member={relationDialog.member} + relationType={relationDialog.type} + onCreateNew={handleCreateNewRelation} + /> + )} ) } diff --git a/context/family-context.tsx b/context/family-context.tsx index 37091030..c9bc4951 100644 --- a/context/family-context.tsx +++ b/context/family-context.tsx @@ -224,10 +224,16 @@ function FamilyProviderInner({ children }: { children: React.ReactNode }) { const updateMember = useCallback(async (id: string, updates: Partial) => { 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) {