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 (
+
+ )
+}
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
@@ -256,6 +287,18 @@ export function TreeLayout({
+
+ {relationDialog && (
+