Files
chinese-family-tree-2/app/members/page.tsx
T
freedakgmail d7bb86375d 0.0.6.1
2025-11-23 16:46:36 +08:00

485 lines
21 KiB
TypeScript

"use client"
import { useState, useMemo, useEffect } 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 { Card, CardContent } from "@/components/ui/card"
import { Search, Plus, Filter, X } 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"
export default function MembersPage() {
const { treeData, searchMembers, setHighlightedMemberId } = useFamily()
const router = useRouter()
const [query, setQuery] = useState("")
const [showFilters, setShowFilters] = useState(false)
// 从 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
useMemo(() => {
if (minGen !== 1000 && maxGen !== 0) {
// Only update if range is default
// This is a bit tricky, for now let's just rely on user adjustment or default wide range
// Ideally we sync this once.
}
}, [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, typeof filteredMembers>()
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.tags?.includes('始祖')
const hasFamilyParents = m.fatherId || m.motherId
// 如果有父母关系,肯定是家族成员
if (hasFamilyParents) return true
// 如果明确标记为始祖,是家族成员
if (isFounder) return true
// 第1世且没有配偶的,可能是始祖(兼容没有标签的情况)
if (generation === 1 && (!m.spouseIds || m.spouseIds.length === 0)) return true
// 其他情况(如第1世但有配偶且没有父母的)是配偶身份
return false
})
// 对家族成员按年龄排序(出生日期从早到晚 = 年龄从高到低)
const sortedFamilyMembers = [...familyMembers].sort((a, b) => {
// 1. 始祖永远排第一
const aIsFounder = a.tags?.includes('始祖')
const bIsFounder = b.tags?.includes('始祖')
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
// 2. 按出生日期排序(早出生的在前 = 年龄大的在前)
const aDate = a.birthDate || "9999-12-31"
const bDate = b.birthDate || "9999-12-31"
return aDate.localeCompare(bDate)
})
// 3. 将配偶插入到主成员后面
const result: typeof members = []
const processedSpouses = new Set<string>()
sortedFamilyMembers.forEach(member => {
result.push(member)
// 计算该家庭单元的成员数(主成员 + 配偶)
let familyUnitSize = 1 // 主成员自己
// 如果有配偶,将配偶紧跟在后面
if (member.spouseIds && member.spouseIds.length > 0) {
member.spouseIds.forEach(spouseId => {
const spouse = members.find(m => m.id === spouseId)
if (spouse && !processedSpouses.has(spouseId)) {
result.push(spouse)
processedSpouses.add(spouseId)
familyUnitSize++
}
})
}
// 计算需要添加的占位符数量,确保填满4的倍数
// 例如:1个主成员 + 1个配偶 = 2人,需要添加2个占位符
// 例如:1个主成员 + 3个配偶 = 4人,不需要占位符
// 例如:1个主成员 + 0个配偶 = 1人,需要添加3个占位符
const placeholdersNeeded = (4 - (familyUnitSize % 4)) % 4
for (let i = 0; i < placeholdersNeeded; i++) {
result.push({ id: `placeholder-${member.id}-${i}`, isPlaceholder: true } as any)
}
})
return {
generation,
members: result
}
})
}, [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)
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1">
<div className="flex flex-col gap-6 mb-8">
<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>
<Link href="/members/new">
<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"> (Surname)</Label>
<Select value={selectedSurname} onValueChange={setSelectedSurname}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (All)</SelectItem>
{uniqueSurnames.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs"> (Ancestral Home)</Label>
<Select value={selectedHome} onValueChange={setSelectedHome}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (All)</SelectItem>
{uniqueHomes.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs"> (Status)</Label>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (All)</SelectItem>
<SelectItem value="living"> (Living)</SelectItem>
<SelectItem value="deceased"> (Deceased)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs"> (Generation)</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="text-center py-12 text-muted-foreground">
</div>
) : (
<div className="space-y-8">
{membersByGeneration.map(({ generation, members }) => (
<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"> {members.length} </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 ((member as any).isPlaceholder) {
return <div key={member.id} className="invisible"></div>
}
// 判断是否是配偶:
// 1. 没有父母ID
// 2. 有配偶ID
// 3. 不是始祖(没有'始祖'标签)
const isFounder = member.tags?.includes('始祖')
const isSpouse = !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
return (
<div key={member.id} className="relative group">
<Link href={`/members/${member.id}`}>
<Card className={`hover:border-primary transition-colors cursor-pointer h-full ${
isSpouse
? 'bg-pink-50/50 dark:bg-pink-950/20 border-pink-200/50 dark:border-pink-800/30'
: 'bg-blue-50/50 dark:bg-blue-950/20 border-blue-200/50 dark:border-blue-800/30'
}`}>
<CardContent className="p-3 flex items-center gap-3">
<div className="relative flex-shrink-0">
<AvatarDisplay
imageId={member.avatarImageId}
fallbackUrl={member.avatarUrl}
fallbackText={member.surname}
gender={member.gender}
className="h-20 w-20 border-2 border-border"
/>
{/* 在世/去世标识 */}
{member.deathDate && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-gray-500 border-2 border-white" title="已故" />
)}
{!member.deathDate && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500 border-2 border-white" title="在世" />
)}
</div>
<div className="flex-1 min-w-0 pl-1">
<div className="flex items-baseline justify-between gap-1.5">
<h3 className="text-base font-bold font-serif truncate">
{member.fullName}
{member.courtesyName && (
<span className="text-[11px] font-normal text-muted-foreground ml-1">
{member.courtesyName}
</span>
)}
</h3>
</div>
{isFounder ? (
<p className="text-[11px] text-amber-700 font-medium mt-0.5 truncate">
</p>
) : isSpouse && spouseName ? (
<p className="text-[11px] text-purple-700 mt-0.5 truncate">
{spouseName}
</p>
) : (
<p className="text-[11px] 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-[11px] text-muted-foreground truncate flex-1">{member.ancestralHome || "籍贯未知"}</p>
{/* 生卒年和年龄 */}
{(member.birthDate || member.deathDate) && (
<div className="text-[10px] text-muted-foreground text-right space-y-0.5 flex-shrink-0">
<div>
{member.birthDate && new Date(member.birthDate).getFullYear()}
{member.deathDate && ` - ${new Date(member.deathDate).getFullYear()}`}
{!member.deathDate && member.birthDate && ' - '}
</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-[9px] 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="text-[9px] bg-primary/10 text-primary px-1 py-0.5 rounded border border-primary/20"
>
{tag}
</span>
))}
</div>
)}
</div>
</CardContent>
</Card>
</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={() => {
setHighlightedMemberId(member.id)
router.push('/tree')
}}
title="在族谱图中定位"
>
<Search className="h-3 w-3" />
</Button>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</main>
</div>
)
}