938 lines
36 KiB
TypeScript
938 lines
36 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useMemo, useEffect, useCallback, memo } from "react"
|
||
import { SiteHeader } from "@/components/site-header"
|
||
import { useFamily } from "@/context/family-context"
|
||
import { useRouter } from "next/navigation"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Button } from "@/components/ui/button"
|
||
import { ChineseCard, ChineseCardContent } from "@/components/ui/chinese-card"
|
||
import { Search, Plus, Filter, X, Users, User, UserPlus, Baby } from "lucide-react"
|
||
import Link from "next/link"
|
||
import { AvatarDisplay } from "@/components/ui/avatar-display"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { Slider } from "@/components/ui/slider"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||
import type { FamilyMember } from "@/types/family"
|
||
import { permissions } from "@/lib/permissions"
|
||
import { MemberNameWithStatus } from "@/components/member-name-with-status"
|
||
import { useSession } from "next-auth/react"
|
||
import { EmptyStateBadge } from "@/components/empty-state-badge"
|
||
import { AddRelationDialog, type RelationType } from "@/components/tree/add-relation-dialog"
|
||
import {
|
||
ContextMenu,
|
||
ContextMenuContent,
|
||
ContextMenuItem,
|
||
ContextMenuLabel,
|
||
ContextMenuSeparator,
|
||
ContextMenuTrigger,
|
||
} from "@/components/ui/context-menu"
|
||
import { GenerationWarningDialog } from "@/components/tree/generation-warning-dialog"
|
||
import { isFirstGenerationMember, shouldShowGenerationWarning } from "@/lib/generation-utils"
|
||
|
||
// 占位符类型
|
||
interface PlaceholderMember {
|
||
id: string
|
||
isPlaceholder: true
|
||
}
|
||
|
||
type MemberOrPlaceholder = FamilyMember | PlaceholderMember
|
||
|
||
// 提取配偶添加逻辑为独立函数(移除占位符,让卡片自然流动排列)
|
||
function addSpousesToFamilyUnit(
|
||
member: FamilyMember,
|
||
spouses: FamilyMember[],
|
||
processedSpouses: Set<string>
|
||
): MemberOrPlaceholder[] {
|
||
const result: MemberOrPlaceholder[] = [member]
|
||
|
||
// 添加配偶(紧跟在主成员后面)
|
||
if (member.spouseIds && member.spouseIds.length > 0) {
|
||
member.spouseIds.forEach(spouseId => {
|
||
const spouse = spouses.find(m => m.id === spouseId)
|
||
if (spouse && !processedSpouses.has(spouseId)) {
|
||
result.push(spouse)
|
||
processedSpouses.add(spouseId)
|
||
}
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// 判断是否是占位符
|
||
function isPlaceholder(member: MemberOrPlaceholder): member is PlaceholderMember {
|
||
return 'isPlaceholder' in member && member.isPlaceholder === true
|
||
}
|
||
|
||
// 成员卡片组件 - 使用 memo 优化
|
||
const MemberCard = memo(function MemberCard({
|
||
member,
|
||
allMembers,
|
||
onLocateInTree,
|
||
currentTreeId,
|
||
canEdit,
|
||
onOpenAddDialog,
|
||
}: {
|
||
member: FamilyMember
|
||
allMembers: FamilyMember[]
|
||
onLocateInTree: (id: string) => void
|
||
currentTreeId?: string
|
||
canEdit: boolean
|
||
onOpenAddDialog: (member: FamilyMember, type: RelationType) => void
|
||
}) {
|
||
// 代数重新计算警告对话框状态
|
||
const [showWarningDialog, setShowWarningDialog] = useState(false)
|
||
const [pendingRelationType, setPendingRelationType] = useState<'father' | 'mother' | null>(null)
|
||
|
||
// 判断是否是始祖(使用 rootMemberId 而不是 isFounder 字段)
|
||
const isFounder = member.id === currentTreeId
|
||
|
||
// 判断是否是配偶
|
||
const isSpouse = !member.fatherId && !member.motherId &&
|
||
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])
|
||
: null
|
||
const spouseName = spouse ? spouse.fullName : null
|
||
|
||
// 获取父母信息
|
||
const father = member.fatherId ? allMembers.find(m => m.id === member.fatherId) : null
|
||
const mother = member.motherId ? allMembers.find(m => m.id === member.motherId) : null
|
||
|
||
// 判断是否已故
|
||
const isDead = !!member.deathDate
|
||
|
||
// 根据在世/已故状态设置卡片样式
|
||
// 优先级:始祖 > 孤立 > 已故 > 在世
|
||
const cardStyle = isFounder
|
||
? '!border-2 !border-amber-400 !bg-amber-50/30 dark:!bg-amber-900/20 shadow-md' // 始祖:金色边框(优先级最高)
|
||
: 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' // 在世:浅金色边框
|
||
|
||
// 判断可以添加的关系
|
||
const canAddFather = !member.fatherId
|
||
const canAddMother = !member.motherId
|
||
const canAddSpouse = true // 配偶总是可以添加
|
||
const canAddChild = true // 子女总是可以添加
|
||
|
||
// 构建新增成员的 URL
|
||
const buildAddUrl = (type: 'father' | 'mother' | 'spouse' | 'child') => {
|
||
const params = new URLSearchParams()
|
||
if (currentTreeId) {
|
||
params.set('treeId', currentTreeId)
|
||
}
|
||
|
||
switch (type) {
|
||
case 'father':
|
||
params.set('childId', member.id)
|
||
params.set('gender', 'MALE')
|
||
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
|
||
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
|
||
// 如果已有母亲,设为配偶
|
||
if (member.motherId) {
|
||
params.set('spouseId', member.motherId)
|
||
}
|
||
break
|
||
case 'mother':
|
||
params.set('childId', member.id)
|
||
params.set('gender', 'FEMALE')
|
||
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
|
||
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
|
||
// 如果已有父亲,设为配偶
|
||
if (member.fatherId) {
|
||
params.set('spouseId', member.fatherId)
|
||
}
|
||
break
|
||
case 'spouse':
|
||
params.set('spouseId', member.id)
|
||
params.set('generation', member.generation.toString())
|
||
// 配偶性别与当前成员相反
|
||
params.set('gender', member.gender === 'MALE' ? 'FEMALE' : 'MALE')
|
||
|
||
// 如果当前成员有子女,将他们也作为新配偶的子女
|
||
if (member.childrenIds && member.childrenIds.length > 0) {
|
||
member.childrenIds.forEach(id => {
|
||
params.append('childrenIds', id)
|
||
})
|
||
}
|
||
break
|
||
case 'child':
|
||
// 根据当前成员性别设置父/母
|
||
if (member.gender === 'MALE') {
|
||
params.set('fatherId', member.id)
|
||
// 如果父亲有配偶,设置配偶为母亲
|
||
if (member.spouseIds && member.spouseIds.length > 0) {
|
||
params.set('motherId', member.spouseIds[0])
|
||
}
|
||
} else {
|
||
params.set('motherId', member.id)
|
||
// 如果母亲有配偶,设置配偶为父亲
|
||
if (member.spouseIds && member.spouseIds.length > 0) {
|
||
params.set('fatherId', member.spouseIds[0])
|
||
}
|
||
}
|
||
params.set('generation', (member.generation + 1).toString())
|
||
break
|
||
}
|
||
|
||
return `/members/new?${params.toString()}`
|
||
}
|
||
|
||
const handleAddMember = (type: RelationType) => {
|
||
// 检查是否需要显示代数重新计算警告
|
||
if ((type === 'father' || type === 'mother') && shouldShowGenerationWarning(member, type)) {
|
||
setPendingRelationType(type)
|
||
setShowWarningDialog(true)
|
||
return
|
||
}
|
||
onOpenAddDialog(member, type)
|
||
}
|
||
|
||
// 确认添加父母后的处理
|
||
const handleConfirmAddParent = () => {
|
||
if (pendingRelationType) {
|
||
onOpenAddDialog(member, pendingRelationType)
|
||
setPendingRelationType(null)
|
||
}
|
||
}
|
||
|
||
// 卡片内容
|
||
const cardContent = (
|
||
<div className="relative group">
|
||
<Link href={`/members/${member.id}${typeof window !== 'undefined' ? window.location.search : ''}`}>
|
||
<ChineseCard variant="default" className={`hover:shadow-lg transition-all cursor-pointer h-full rounded-xl ${cardStyle}`}>
|
||
<ChineseCardContent className="px-4 py-3 flex items-center gap-5">
|
||
<div className="relative flex-shrink-0">
|
||
<AvatarDisplay
|
||
imageUrl={member.avatarUrl}
|
||
fallbackText={member.surname}
|
||
gender={member.gender}
|
||
className="h-20 w-20 border-2 border-border"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||
<div className="flex items-baseline justify-between gap-1.5">
|
||
<h3 className="text-lg font-bold font-serif truncate flex items-center gap-1">
|
||
{isFounder && <span className="text-amber-600">👑</span>}
|
||
<MemberNameWithStatus
|
||
name={member.fullName}
|
||
isDead={!!member.deathDate}
|
||
dotPosition="top-left"
|
||
/>
|
||
{member.courtesyName && (
|
||
<span className="text-xs font-normal text-muted-foreground ml-1">
|
||
字 {member.courtesyName}
|
||
</span>
|
||
)}
|
||
</h3>
|
||
{/* 父母信息 - 放在姓名右侧 */}
|
||
{(father || mother) && (
|
||
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
|
||
<span className="text-muted-foreground/70">父母:</span>
|
||
{father && (
|
||
<span
|
||
className="hover:text-primary hover:underline cursor-pointer"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
window.location.href = `/members/${father.id}${window.location.search}`
|
||
}}
|
||
>
|
||
<MemberNameWithStatus
|
||
name={father.fullName}
|
||
isDead={!!father.deathDate}
|
||
dotPosition="top-left"
|
||
/>
|
||
</span>
|
||
)}
|
||
{father && mother && <span className="text-muted-foreground/50">·</span>}
|
||
{mother && (
|
||
<span
|
||
className="hover:text-primary hover:underline cursor-pointer"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
window.location.href = `/members/${mother.id}${window.location.search}`
|
||
}}
|
||
>
|
||
<MemberNameWithStatus
|
||
name={mother.fullName}
|
||
isDead={!!mother.deathDate}
|
||
dotPosition="top-left"
|
||
/>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isFounder ? (
|
||
<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}的配偶
|
||
</p>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||
{member.generationName ? `字辈: ${member.generationName}` : "未录入字辈"}
|
||
</p>
|
||
)}
|
||
|
||
{/* 籍贯和生卒年并排 */}
|
||
<div className="flex items-start justify-between gap-2 mt-0.5">
|
||
<p className="text-xs text-muted-foreground truncate flex-1">{member.ancestralHome || "籍贯未知"}</p>
|
||
|
||
{/* 生卒年和年龄 */}
|
||
{(member.birthDate || member.deathDate) && (
|
||
<div className="text-xs text-muted-foreground text-right space-y-0.5 flex-shrink-0">
|
||
<div className="flex items-center gap-1 justify-end">
|
||
<span>
|
||
{member.birthDate && new Date(member.birthDate).getFullYear()}
|
||
{member.deathDate && ` - ${new Date(member.deathDate).getFullYear()}`}
|
||
{!member.deathDate && member.birthDate && ' - '}
|
||
</span>
|
||
{member.isLunarDate && (
|
||
<span className="text-[10px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200">
|
||
农历
|
||
</span>
|
||
)}
|
||
</div>
|
||
{(() => {
|
||
if (!member.birthDate) return null
|
||
const birthYear = new Date(member.birthDate).getFullYear()
|
||
const deathYear = member.deathDate ? new Date(member.deathDate).getFullYear() : new Date().getFullYear()
|
||
const age = deathYear - birthYear
|
||
return (
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{member.deathDate ? `享年${age}岁` : `${age}岁`}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{member.tags && member.tags.length > 0 && (
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
{member.tags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className={tag === '始祖'
|
||
? "text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded border border-amber-300 font-semibold"
|
||
: "text-xs bg-primary/10 text-primary px-2 py-0.5 rounded border border-primary/20"}
|
||
>
|
||
{tag === '始祖' ? '👑 始祖' : tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ChineseCardContent>
|
||
</ChineseCard>
|
||
</Link>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||
onClick={() => onLocateInTree(member.id)}
|
||
title="在族谱图中定位"
|
||
>
|
||
<Search className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)
|
||
|
||
// 如果没有编辑权限,直接返回卡片内容
|
||
if (!canEdit) {
|
||
return cardContent
|
||
}
|
||
|
||
// 有编辑权限时,包裹右键菜单
|
||
return (
|
||
<>
|
||
<ContextMenu>
|
||
<ContextMenuTrigger asChild>
|
||
{cardContent}
|
||
</ContextMenuTrigger>
|
||
<ContextMenuContent className="w-48">
|
||
<ContextMenuLabel className="text-xs text-muted-foreground">
|
||
为 {member.fullName} 添加
|
||
</ContextMenuLabel>
|
||
<ContextMenuSeparator />
|
||
|
||
{canAddFather && (
|
||
<ContextMenuItem
|
||
className="gap-2 cursor-pointer"
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
handleAddMember('father')
|
||
}}
|
||
>
|
||
<UserPlus className="h-4 w-4 text-blue-500" />
|
||
添加父亲
|
||
</ContextMenuItem>
|
||
)}
|
||
|
||
{canAddMother && (
|
||
<ContextMenuItem
|
||
className="gap-2 cursor-pointer"
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
handleAddMember('mother')
|
||
}}
|
||
>
|
||
<UserPlus className="h-4 w-4 text-pink-500" />
|
||
添加母亲
|
||
</ContextMenuItem>
|
||
)}
|
||
|
||
{(canAddFather || canAddMother) && (canAddSpouse || canAddChild) && (
|
||
<ContextMenuSeparator />
|
||
)}
|
||
|
||
{canAddSpouse && (
|
||
<ContextMenuItem
|
||
className="gap-2 cursor-pointer"
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
handleAddMember('spouse')
|
||
}}
|
||
>
|
||
<Users className="h-4 w-4 text-red-500" />
|
||
添加配偶
|
||
</ContextMenuItem>
|
||
)}
|
||
|
||
{canAddChild && (
|
||
<ContextMenuItem
|
||
className="gap-2 cursor-pointer"
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
handleAddMember('child')
|
||
}}
|
||
>
|
||
<Baby className="h-4 w-4 text-green-500" />
|
||
添加子女
|
||
</ContextMenuItem>
|
||
)}
|
||
</ContextMenuContent>
|
||
</ContextMenu>
|
||
|
||
{/* 代数重新计算警告对话框 */}
|
||
{pendingRelationType && (
|
||
<GenerationWarningDialog
|
||
open={showWarningDialog}
|
||
onOpenChange={setShowWarningDialog}
|
||
memberName={member.fullName}
|
||
relationType={pendingRelationType}
|
||
onConfirm={handleConfirmAddParent}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
})
|
||
|
||
export default function MembersPage() {
|
||
const { treeData, currentTree, searchMembers, setHighlightedMemberId, refreshData } = useFamily()
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const [query, setQuery] = useState("")
|
||
const [showFilters, setShowFilters] = useState(false)
|
||
|
||
// 添加成员对话框状态
|
||
const [relationDialog, setRelationDialog] = useState<{
|
||
member: FamilyMember
|
||
type: RelationType
|
||
} | null>(null)
|
||
|
||
|
||
// 打开添加成员对话框
|
||
const handleOpenAddDialog = useCallback((member: FamilyMember, type: RelationType) => {
|
||
setRelationDialog({ member, type })
|
||
}, [])
|
||
|
||
// 姓名分解函数
|
||
const parseName = (fullName: string): { surname: string; givenName: string } => {
|
||
const name = fullName.trim()
|
||
if (!name) return { surname: '', givenName: '' }
|
||
|
||
const compoundSurnames = [
|
||
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
|
||
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
|
||
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
|
||
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
|
||
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
|
||
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
|
||
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
|
||
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
|
||
]
|
||
|
||
for (const compound of compoundSurnames) {
|
||
if (name.startsWith(compound) && name.length > compound.length) {
|
||
return { surname: compound, givenName: name.slice(compound.length) }
|
||
}
|
||
}
|
||
|
||
if (name.length >= 2) {
|
||
return { surname: name.charAt(0), givenName: name.slice(1) }
|
||
}
|
||
|
||
return { surname: name, givenName: '' }
|
||
}
|
||
|
||
// 创建新成员的处理函数
|
||
const handleCreateNewRelation = useCallback((prefillName: string, relationType: RelationType) => {
|
||
if (!relationDialog) return
|
||
const params = new URLSearchParams()
|
||
|
||
const { surname, givenName } = parseName(prefillName)
|
||
if (surname) params.set("surname", surname)
|
||
if (givenName) params.set("givenName", givenName)
|
||
|
||
if (relationType === "child") {
|
||
params.set("generation", String(relationDialog.member.generation + 1))
|
||
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 if (relationType === "spouse") {
|
||
params.set("generation", String(relationDialog.member.generation))
|
||
params.set("spouseId", relationDialog.member.id)
|
||
params.set("gender", relationDialog.member.gender === "MALE" ? "FEMALE" : "MALE")
|
||
if (relationDialog.member.childrenIds && relationDialog.member.childrenIds.length > 0) {
|
||
relationDialog.member.childrenIds.forEach(id => {
|
||
params.append('childrenIds', id)
|
||
})
|
||
}
|
||
} else if (relationType === "father") {
|
||
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
|
||
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
|
||
params.set("childId", relationDialog.member.id)
|
||
params.set("gender", "MALE")
|
||
if (relationDialog.member.motherId) {
|
||
params.set("spouseId", relationDialog.member.motherId)
|
||
}
|
||
} else if (relationType === "mother") {
|
||
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
|
||
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
|
||
params.set("childId", relationDialog.member.id)
|
||
params.set("gender", "FEMALE")
|
||
if (relationDialog.member.fatherId) {
|
||
params.set("spouseId", relationDialog.member.fatherId)
|
||
}
|
||
}
|
||
|
||
if (currentTree?.id) {
|
||
params.set("treeId", currentTree.id)
|
||
}
|
||
|
||
setRelationDialog(null)
|
||
router.push(`/members/new?${params.toString()}`)
|
||
}, [relationDialog, currentTree?.id, router])
|
||
|
||
// 未登录时重定向到登录页
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/auth/signin')
|
||
}
|
||
}, [status, router])
|
||
|
||
// 页面加载时刷新数据
|
||
useEffect(() => {
|
||
if (currentTree?.id) {
|
||
refreshData()
|
||
}
|
||
}, [currentTree?.id, refreshData])
|
||
|
||
// 从 URL 参数读取搜索关键词
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search)
|
||
const searchParam = params.get('search')
|
||
if (searchParam) {
|
||
setQuery(searchParam)
|
||
}
|
||
}, [])
|
||
|
||
// Filter States
|
||
const [selectedSurname, setSelectedSurname] = useState<string>("all")
|
||
const [selectedHome, setSelectedHome] = useState<string>("all")
|
||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||
const [generationRange, setGenerationRange] = useState<[number, number]>([1, 100])
|
||
|
||
// Derived Data
|
||
const allMembers = useMemo(() => Object.values(treeData.members), [treeData])
|
||
|
||
const { uniqueSurnames, uniqueHomes, minGen, maxGen } = useMemo(() => {
|
||
const surnames = new Set<string>()
|
||
const homes = new Set<string>()
|
||
let min = 1000
|
||
let max = 0
|
||
|
||
allMembers.forEach((m) => {
|
||
if (m.surname) surnames.add(m.surname)
|
||
if (m.ancestralHome) homes.add(m.ancestralHome)
|
||
if (m.generation) {
|
||
if (m.generation < min) min = m.generation
|
||
if (m.generation > max) max = m.generation
|
||
}
|
||
})
|
||
|
||
return {
|
||
uniqueSurnames: Array.from(surnames).sort(),
|
||
uniqueHomes: Array.from(homes).sort(),
|
||
minGen: min === 1000 ? 1 : min,
|
||
maxGen: max === 0 ? 100 : max,
|
||
}
|
||
}, [allMembers])
|
||
|
||
// Initialize slider when data loads - 修复:使用 useEffect 而不是 useMemo
|
||
useEffect(() => {
|
||
if (minGen !== 1000 && maxGen !== 0) {
|
||
// 只在初始加载时设置一次
|
||
setGenerationRange([minGen, maxGen])
|
||
}
|
||
}, [minGen, maxGen])
|
||
|
||
const filteredMembers = useMemo(() => {
|
||
let result = allMembers
|
||
|
||
// 1. Search Query
|
||
if (query) {
|
||
const lowerQuery = query.toLowerCase()
|
||
result = result.filter(
|
||
(m) =>
|
||
m.fullName.toLowerCase().includes(lowerQuery) ||
|
||
m.courtesyName?.toLowerCase().includes(lowerQuery) ||
|
||
m.artName?.toLowerCase().includes(lowerQuery),
|
||
)
|
||
}
|
||
|
||
// 2. Surname Filter
|
||
if (selectedSurname !== "all") {
|
||
result = result.filter((m) => m.surname === selectedSurname)
|
||
}
|
||
|
||
// 3. Home Filter
|
||
if (selectedHome !== "all") {
|
||
result = result.filter((m) => m.ancestralHome === selectedHome)
|
||
}
|
||
|
||
// 4. Status Filter
|
||
if (statusFilter !== "all") {
|
||
if (statusFilter === "living") {
|
||
result = result.filter((m) => !m.deathDate)
|
||
} else if (statusFilter === "deceased") {
|
||
result = result.filter((m) => !!m.deathDate)
|
||
}
|
||
}
|
||
|
||
// 5. Generation Filter
|
||
result = result.filter((m) => m.generation >= generationRange[0] && m.generation <= generationRange[1])
|
||
|
||
// Sort
|
||
return result.sort((a, b) => a.generation - b.generation || (a.birthDate || "").localeCompare(b.birthDate || ""))
|
||
}, [allMembers, query, selectedSurname, selectedHome, statusFilter, generationRange])
|
||
|
||
// 按世代分组并排序 - 优化后的版本
|
||
const membersByGeneration = useMemo(() => {
|
||
const groups = new Map<number, FamilyMember[]>()
|
||
|
||
// 第一步:按世代分组
|
||
filteredMembers.forEach(member => {
|
||
const gen = member.generation
|
||
if (!groups.has(gen)) {
|
||
groups.set(gen, [])
|
||
}
|
||
groups.get(gen)!.push(member)
|
||
})
|
||
|
||
// 第二步:转换为数组并排序
|
||
return Array.from(groups.entries())
|
||
.sort((a, b) => a[0] - b[0])
|
||
.map(([generation, members]) => {
|
||
// 区分家族成员和配偶
|
||
// 配偶定义:没有父母但有配偶的成员(嫁入/娶入的成员)
|
||
// 其他所有成员都应该显示,包括孤立成员
|
||
const familyMembers = members.filter(m => {
|
||
// 使用 treeData.rootId 判断始祖,而不是 isFounder 字段
|
||
const isFounder = m.id === treeData.rootId
|
||
const hasFamilyParents = m.fatherId || m.motherId
|
||
const hasSpouse = m.spouseIds && m.spouseIds.length > 0
|
||
|
||
// 始祖或有父母的成员:显示
|
||
if (hasFamilyParents || isFounder) return true
|
||
|
||
// 第一代无配偶成员:显示
|
||
if (generation === 1 && !hasSpouse) return true
|
||
|
||
// 孤立成员(无父母、无配偶、非始祖):也要显示,方便用户关联
|
||
if (!hasFamilyParents && !hasSpouse && !isFounder) return true
|
||
|
||
// 配偶(无父母但有配偶):不在这里显示,会跟随主成员显示
|
||
return false
|
||
})
|
||
|
||
// 对家族成员按年龄排序
|
||
const sortedFamilyMembers = familyMembers.sort((a, b) => {
|
||
// 使用 treeData.rootId 判断始祖
|
||
const aIsFounder = a.id === treeData.rootId
|
||
const bIsFounder = b.id === treeData.rootId
|
||
if (aIsFounder && !bIsFounder) return -1
|
||
if (!aIsFounder && bIsFounder) return 1
|
||
|
||
const aDate = a.birthDate || "9999-12-31"
|
||
const bDate = b.birthDate || "9999-12-31"
|
||
return aDate.localeCompare(bDate)
|
||
})
|
||
|
||
// 添加配偶(紧跟在主成员后面,不使用占位符)
|
||
const result: MemberOrPlaceholder[] = []
|
||
const processedSpouses = new Set<string>()
|
||
|
||
sortedFamilyMembers.forEach(member => {
|
||
const familyUnit = addSpousesToFamilyUnit(member, members, processedSpouses)
|
||
result.push(...familyUnit)
|
||
})
|
||
|
||
// 计算实际成员数(不包括占位符)
|
||
const actualMemberCount = result.filter(m => !isPlaceholder(m)).length
|
||
|
||
return {
|
||
generation,
|
||
members: result,
|
||
actualCount: actualMemberCount
|
||
}
|
||
})
|
||
}, [filteredMembers, treeData.rootId])
|
||
|
||
const clearFilters = () => {
|
||
setSelectedSurname("all")
|
||
setSelectedHome("all")
|
||
setStatusFilter("all")
|
||
setGenerationRange([1, 100]) // Reset to wide range
|
||
setQuery("")
|
||
}
|
||
|
||
const activeFilterCount =
|
||
(selectedSurname !== "all" ? 1 : 0) +
|
||
(selectedHome !== "all" ? 1 : 0) +
|
||
(statusFilter !== "all" ? 1 : 0) +
|
||
(generationRange[0] > 1 || generationRange[1] < 100 ? 1 : 0)
|
||
|
||
// 未登录时不渲染内容(已在 useEffect 中重定向)
|
||
if (status === 'loading' || !session) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className="h-screen bg-background flex flex-col font-sans overflow-hidden">
|
||
<SiteHeader />
|
||
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 flex flex-col overflow-hidden">
|
||
<div className="flex flex-col gap-6 mb-8 flex-shrink-0">
|
||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||
<h1 className="text-3xl font-serif font-bold">家族成员列表</h1>
|
||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||
<div className="relative flex-1 md:w-80">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
type="search"
|
||
placeholder="搜索姓名、字号..."
|
||
className="pl-9"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant={showFilters || activeFilterCount > 0 ? "secondary" : "outline"}
|
||
size="icon"
|
||
onClick={() => setShowFilters(!showFilters)}
|
||
className="relative"
|
||
>
|
||
<Filter className="h-4 w-4" />
|
||
{activeFilterCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-primary border-2 border-background" />
|
||
)}
|
||
</Button>
|
||
{permissions.canCreate(currentTree?.currentUserRole) && (
|
||
<Link href={`/members/new${typeof window !== 'undefined' ? window.location.search : ''}`}>
|
||
<Button className="gap-2 bg-primary text-primary-foreground">
|
||
<Plus className="h-4 w-4" />
|
||
新增成员
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter Bar */}
|
||
{showFilters && (
|
||
<div className="bg-muted/30 p-4 rounded-lg border border-border space-y-4 animate-in slide-in-from-top-2">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-medium text-sm text-muted-foreground">高级筛选</h3>
|
||
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-8 text-xs">
|
||
<X className="mr-1 h-3 w-3" /> 重置筛选
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">姓氏</Label>
|
||
<Select value={selectedSurname} onValueChange={setSelectedSurname}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="全部" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部</SelectItem>
|
||
{uniqueSurnames.map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{s}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">籍贯</Label>
|
||
<Select value={selectedHome} onValueChange={setSelectedHome}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="全部" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部</SelectItem>
|
||
{uniqueHomes.map((h) => (
|
||
<SelectItem key={h} value={h}>
|
||
{h}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">状态</Label>
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="全部" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部</SelectItem>
|
||
<SelectItem value="living">在世</SelectItem>
|
||
<SelectItem value="deceased">已故</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between">
|
||
<Label className="text-xs">世系范围</Label>
|
||
<span className="text-xs text-muted-foreground">
|
||
{generationRange[0]} - {generationRange[1]} 世
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
defaultValue={[1, 100]}
|
||
value={generationRange}
|
||
min={1}
|
||
max={100} // Can be dynamic based on maxGen
|
||
step={1}
|
||
onValueChange={(val) => setGenerationRange(val as [number, number])}
|
||
className="py-4"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{filteredMembers.length === 0 ? (
|
||
<div className="flex-1 flex items-center justify-center -mt-20">
|
||
<div className="text-center space-y-4">
|
||
<div className="inline-flex items-center justify-center w-16 h-16 text-primary/80">
|
||
<Search className="h-16 w-16" strokeWidth={1} />
|
||
</div>
|
||
<p className="text-base text-foreground font-light tracking-wide">未寻得</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8 overflow-y-auto flex-1 pb-8">
|
||
{membersByGeneration.map(({ generation, members, actualCount }) => (
|
||
<div key={generation}>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<h2 className="text-xl font-serif font-bold text-foreground">第 {generation} 世</h2>
|
||
<span className="text-sm text-muted-foreground">共 {actualCount} 人</span>
|
||
<div className="flex-1 h-px bg-border"></div>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||
{members.map((member) => {
|
||
// 检查是否是占位符
|
||
if (isPlaceholder(member)) {
|
||
return <div key={member.id} className="invisible"></div>
|
||
}
|
||
|
||
// 使用优化后的 MemberCard 组件
|
||
return (
|
||
<MemberCard
|
||
key={member.id}
|
||
member={member}
|
||
allMembers={allMembers}
|
||
onLocateInTree={(id) => {
|
||
setHighlightedMemberId(id)
|
||
const params = new URLSearchParams(window.location.search)
|
||
router.push(`/tree?${params.toString()}`)
|
||
}}
|
||
currentTreeId={treeData.rootId || undefined}
|
||
canEdit={permissions.canCreate(currentTree?.currentUserRole)}
|
||
onOpenAddDialog={handleOpenAddDialog}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{/* 添加成员对话框 */}
|
||
{relationDialog && (
|
||
<AddRelationDialog
|
||
open={!!relationDialog}
|
||
onOpenChange={(open) => {
|
||
if (!open) setRelationDialog(null)
|
||
}}
|
||
member={relationDialog.member}
|
||
relationType={relationDialog.type}
|
||
onCreateNew={handleCreateNewRelation}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|