Files
chinese-family-tree-2/app/members/page.tsx
T
freedakgmail 0326894b09 0.3.1.3
2025-11-30 20:00:18 +08:00

623 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 } 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"
// 占位符类型
interface PlaceholderMember {
id: string
isPlaceholder: true
}
type MemberOrPlaceholder = FamilyMember | PlaceholderMember
// 提取占位符生成逻辑为独立函数
function addPlaceholdersToFamilyUnit(
member: FamilyMember,
spouses: FamilyMember[],
processedSpouses: Set<string>
): MemberOrPlaceholder[] {
const result: MemberOrPlaceholder[] = [member]
let familyUnitSize = 1 // 主成员自己
// 添加配偶
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)
familyUnitSize++
}
})
}
// 计算需要添加的占位符数量,确保填满4的倍数
const placeholdersNeeded = (4 - (familyUnitSize % 4)) % 4
for (let i = 0; i < placeholdersNeeded; i++) {
result.push({ id: `placeholder-${member.id}-${i}`, isPlaceholder: true })
}
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,
}: {
member: FamilyMember
allMembers: FamilyMember[]
onLocateInTree: (id: string) => void
}) {
// 判断是否是配偶
const isFounder = member.isFounder === true
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' // 在世:浅金色边框
return (
<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 ${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>
)
})
export default function MembersPage() {
const { treeData, currentTree, searchMembers, setHighlightedMemberId } = useFamily()
const { data: session, status } = useSession()
const router = useRouter()
const [query, setQuery] = useState("")
const [showFilters, setShowFilters] = useState(false)
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
// 从 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 => {
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 && !hasSpouse) return true
// 孤立成员(无父母、无配偶、非始祖):也要显示,方便用户关联
if (!hasFamilyParents && !hasSpouse && !isFounder) return true
// 配偶(无父母但有配偶):不在这里显示,会跟随主成员显示
return false
})
// 对家族成员按年龄排序
const sortedFamilyMembers = familyMembers.sort((a, b) => {
const aIsFounder = a.isFounder === true
const bIsFounder = b.isFounder === true
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 = addPlaceholdersToFamilyUnit(member, members, processedSpouses)
result.push(...familyUnit)
})
// 计算实际成员数(不包括占位符)
const actualMemberCount = result.filter(m => !isPlaceholder(m)).length
return {
generation,
members: result,
actualCount: actualMemberCount
}
})
}, [filteredMembers])
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()}`)
}}
/>
)
})}
</div>
</div>
))}
</div>
)}
</main>
</div>
)
}