Files
chinese-family-tree-2/app/members/[id]/page.tsx
T
freedakgmail 72a59b62a0 0.3.0.2
2025-11-30 10:52:36 +08:00

719 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { useFamily } from "@/context/family-context"
import { SiteHeader } from "@/components/site-header"
import { MemberForm } from "@/components/members/member-form"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { ArrowLeft, Edit, Trash, Share2, Printer, CircleUser, CircleUserRound, Images, BookOpen, UserPlus, Camera } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import { PhotoGallery } from "@/components/members/photo-gallery"
import { StoryManager } from "@/components/members/story-manager"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import { permissions } from "@/lib/permissions"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { MemberVersionHistory } from "@/components/members/member-version-history"
import { useSession } from "next-auth/react"
export default function MemberProfilePage() {
const { id } = useParams()
const { treeData, currentTree, getMember, updateMember, deleteMember, isLoading } = useFamily()
const role = currentTree?.currentUserRole
const { showConfirm } = useDialog()
const router = useRouter()
const { data: session, status } = useSession()
const [isEditing, setIsEditing] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | undefined>(undefined)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [lastSaveTime, setLastSaveTime] = useState(Date.now())
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const member = getMember(id as string)
const allMembers = Object.values(treeData.members)
// 始祖专用边框样式
const founderBorderClass = member?.isFounder ? "border-2 border-amber-400 shadow-md" : "border border-border"
// 计算年龄或享年
const calculateAge = () => {
if (!member?.birthDate) return null
const birthYear = new Date(member.birthDate).getFullYear()
if (member.deathDate) {
// 已故,计算享年
const deathYear = new Date(member.deathDate).getFullYear()
return { age: deathYear - birthYear, type: 'deceased' }
} else {
// 在世,计算年龄
const currentYear = new Date().getFullYear()
return { age: currentYear - birthYear, type: 'living' }
}
}
const ageInfo = calculateAge()
// Load avatar
useEffect(() => {
if (member?.avatarUrl) {
setAvatarBlobUrl(member.avatarUrl)
} else {
setAvatarBlobUrl(undefined)
}
}, [member?.avatarUrl])
// 显示加载状态
if (isLoading) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">...</p>
</div>
</div>
)
}
// 成员未找到
if (!member) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<p className="text-muted-foreground"></p>
<Button onClick={() => router.push(`/members${typeof window !== 'undefined' ? window.location.search : ''}`)}></Button>
</div>
</div>
)
}
const handleSave = async (updatedMember: FamilyMember) => {
await updateMember(updatedMember.id, updatedMember)
setIsEditing(false)
setLastSaveTime(Date.now())
}
const handleDelete = async () => {
const confirmed = await showConfirm("确定要删除这位成员吗?这将同时删除其关联关系。", "删除成员", "destructive")
if (confirmed) {
deleteMember(member.id)
const params = new URLSearchParams(window.location.search)
router.push(`/members?${params.toString()}`)
}
}
const handleQuickAddChild = () => {
// 跳转到新增成员页面,并预填父母信息
const params = new URLSearchParams(window.location.search)
if (member.gender === 'MALE') {
params.set('fatherId', member.id)
} else {
params.set('motherId', member.id)
}
params.set('generation', (member.generation + 1).toString())
router.push(`/members/new?${params.toString()}`)
}
const handleQuickAddSpouse = () => {
// 跳转到新增成员页面,并预填配偶信息
const params = new URLSearchParams(window.location.search)
params.set('spouseId', member.id)
params.set('generation', member.generation.toString())
// 配偶性别与当前成员相反
params.set('gender', member.gender === 'MALE' ? 'FEMALE' : 'MALE')
router.push(`/members/new?${params.toString()}`)
}
const handleQuickAddFather = () => {
// 跳转到新增成员页面,并预填父亲信息
const params = new URLSearchParams(window.location.search)
params.set('childId', member.id)
params.set('generation', (member.generation - 1).toString())
params.set('gender', 'MALE')
// 如果已有母亲,将母亲设置为配偶
if (member.motherId) {
params.set('spouseId', member.motherId)
}
router.push(`/members/new?${params.toString()}`)
}
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
setIsUploadingAvatar(true)
// 上传到服务器
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || '上传失败')
}
const { url } = await response.json()
// 更新成员头像 URL
await updateMember(member.id, {
avatarUrl: url,
})
// 显示新头像
setAvatarBlobUrl(url)
setLastSaveTime(Date.now())
} catch (error) {
console.error('上传头像失败:', error)
alert('上传头像失败: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setIsUploadingAvatar(false)
}
}
const handleQuickAddMother = () => {
// 跳转到新增成员页面,并预填母亲信息
const params = new URLSearchParams(window.location.search)
params.set('childId', member.id)
params.set('generation', (member.generation - 1).toString())
params.set('gender', 'FEMALE')
// 如果已有父亲,将父亲设置为配偶
if (member.fatherId) {
params.set('spouseId', member.fatherId)
}
router.push(`/members/new?${params.toString()}`)
}
if (isEditing) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="container mx-auto py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => setIsEditing(false)} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
{member.isFounder && (
<div className="mb-6 p-6 bg-gradient-to-br from-amber-50 via-orange-50 to-amber-50 border-2 border-amber-400 rounded-xl shadow-md">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full flex items-center justify-center shadow-sm">
<span className="text-3xl">👑</span>
</div>
<div className="flex-1">
<h2 className="text-2xl font-serif font-bold text-amber-900 mb-3">
</h2>
<p className="text-base text-amber-800 leading-relaxed font-serif">
<span className="font-bold text-amber-900"></span>
<span className="font-bold text-amber-900"></span>
<br />
</p>
</div>
</div>
</div>
)}
<h1 className="text-3xl font-serif font-bold mb-2">
{member.isFounder ? '编辑家族始祖' : `编辑成员: ${member.fullName}`}
</h1>
<p className="text-muted-foreground">
{member.isFounder
? '修改始祖信息将影响整个家族树'
: '更新成员信息'}
</p>
</div>
<MemberForm
initialData={member}
existingMembers={allMembers}
onSubmit={handleSave}
onCancel={() => setIsEditing(false)}
isFounder={member.isFounder}
/>
</div>
</div>
)
}
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6">
<Tabs defaultValue="family" className="flex flex-col">
{/* 固定头部区域 - top-16 对应 SiteHeader 的 h-16 高度 */}
<div className="sticky top-16 z-30 bg-background pb-4 -mx-4 px-4 md:-mx-6 md:px-6 border-b border-border -mt-8 pt-8">
{/* 返回按钮 */}
<div className="py-4">
<Button
variant="ghost"
onClick={() => router.push(`/members${typeof window !== 'undefined' ? window.location.search : ''}`)}
className="gap-2 pl-0 hover:bg-transparent hover:text-primary text-2xl font-bold"
>
<ArrowLeft className="h-6 w-6" />
</Button>
</div>
{/* Header Section */}
<div className="flex flex-col md:flex-row justify-between items-start gap-6 pb-4">
<div className="flex items-start gap-6">
<div className="relative group">
<div className="h-24 w-24 rounded-lg bg-muted flex items-center justify-center border-2 border-primary/20 overflow-hidden">
{avatarBlobUrl ? (
<img
src={avatarBlobUrl}
alt={member.fullName}
className="h-full w-full object-cover"
/>
) : (
member.gender === 'FEMALE' ? (
<CircleUserRound className="h-16 w-16 text-pink-400" />
) : (
<CircleUser className="h-16 w-16 text-blue-400" />
)
)}
</div>
{permissions.canUpdate(role) && (
<label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer rounded-lg">
<Camera className="h-6 w-6 text-white" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={isUploadingAvatar}
/>
</label>
)}
{isUploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg">
<div className="text-white text-xs">...</div>
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold font-serif text-foreground flex items-center gap-2">
{member.isFounder && <span className="text-amber-600 text-2xl">👑</span>}
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
memberId={member.id}
treeId={currentTree?.id}
/>
</h1>
{member.isFounder ? (
<span className="px-3 py-1 bg-gradient-to-r from-amber-100 to-orange-100 text-amber-800 text-sm font-semibold rounded-full border-2 border-amber-300 shadow-sm">
· {member.generation}
</span>
) : (
<span className="px-2 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full border border-primary/20">
{member.generation}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{member.courtesyName && <span>{member.courtesyName}</span>}
{member.artName && <span>{member.artName}</span>}
{member.generationName && <span>{member.generationName}</span>}
{ageInfo && <span>{ageInfo.type === 'deceased' ? '享年' : '年龄'}{ageInfo.age}</span>}
</div>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{permissions.canCreate(role) && (
<>
{!member.isFounder && !member.fatherId && (
<Button variant="outline" size="sm" onClick={handleQuickAddFather} className="gap-1">
<UserPlus className="h-4 w-4" />
</Button>
)}
{!member.isFounder && !member.motherId && (
<Button variant="outline" size="sm" onClick={handleQuickAddMother} className="gap-1">
<UserPlus className="h-4 w-4" />
</Button>
)}
<Button variant="default" size="sm" onClick={handleQuickAddChild} className="gap-1">
<UserPlus className="h-4 w-4" />
</Button>
<Button variant="secondary" size="sm" onClick={handleQuickAddSpouse} className="gap-1">
<UserPlus className="h-4 w-4" />
</Button>
</>
)}
<Button variant="outline" size="icon" onClick={() => window.print()} title="打印">
<Printer className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" title="分享">
<Share2 className="h-4 w-4" />
</Button>
{permissions.canUpdate(role) && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)} className="gap-1">
<Edit className="h-4 w-4" />
</Button>
)}
{permissions.canDeleteMember(role) && (
<Button variant="destructive" size="icon" onClick={handleDelete} title="删除">
<Trash className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Tab 导航栏 */}
<TabsList className="flex flex-wrap gap-2 bg-muted/70 p-1 w-fit">
<TabsTrigger value="family"></TabsTrigger>
<TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="life"></TabsTrigger>
<TabsTrigger value="stories"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
</TabsList>
</div>
{/* 始祖特殊提示框 */}
{member.isFounder && (
<div className="mt-6 p-6 bg-gradient-to-br from-amber-50 via-orange-50 to-amber-50 border-2 border-amber-400 rounded-xl shadow-md">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full flex items-center justify-center shadow-sm">
<span className="text-3xl">👑</span>
</div>
<div className="flex-1">
<h2 className="text-2xl font-serif font-bold text-amber-900 mb-3">
</h2>
<p className="text-base text-amber-800 leading-relaxed font-serif">
<span className="font-bold text-amber-900"></span>
<span className="font-bold text-amber-900"></span>
<br />
</p>
</div>
</div>
</div>
)}
{/* Tab 内容区域 */}
<TabsContent value="family" className="mt-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={`bg-card p-4 rounded-lg ${founderBorderClass}`}>
<h3 className="font-medium text-muted-foreground mb-3 text-sm uppercase tracking-wider">
</h3>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-1"></p>
{member.fatherId ? (
<Link
href={`/members/${member.fatherId}${typeof window !== 'undefined' ? window.location.search : ''}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
<MemberNameWithStatus
name={getMember(member.fatherId)?.fullName || member.fatherId}
isDead={!!getMember(member.fatherId)?.deathDate}
/>
</Link>
) : member.spouseFatherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseFatherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-1"></p>
{member.motherId ? (
<Link
href={`/members/${member.motherId}${typeof window !== 'undefined' ? window.location.search : ''}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
<MemberNameWithStatus
name={getMember(member.motherId)?.fullName || member.motherId}
isDead={!!getMember(member.motherId)?.deathDate}
/>
</Link>
) : member.spouseMotherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseMotherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
</div>
<div className={`bg-card p-4 rounded-lg ${founderBorderClass}`}>
<h3 className="font-medium text-muted-foreground mb-3 text-sm uppercase tracking-wider">
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-2"></p>
<div className="flex flex-wrap gap-2">
{member.spouseIds?.length > 0 ? (
member.spouseIds.map((spouseId) => (
<Link
href={`/members/${spouseId}${typeof window !== 'undefined' ? window.location.search : ''}`}
key={spouseId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
<MemberNameWithStatus
name={getMember(spouseId)?.fullName || spouseId}
isDead={!!getMember(spouseId)?.deathDate}
/>
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-2"></p>
<div className="flex flex-wrap gap-2">
{member.childrenIds?.length > 0 ? (
member.childrenIds.map((childId) => (
<Link
href={`/members/${childId}${typeof window !== 'undefined' ? window.location.search : ''}`}
key={childId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
<MemberNameWithStatus
name={getMember(childId)?.fullName || childId}
isDead={!!getMember(childId)?.deathDate}
/>
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="details" className="mt-6 space-y-6">
<div className={`bg-card rounded-lg ${founderBorderClass} p-6 shadow-sm w-full`}>
<h3 className="text-lg font-serif font-semibold mb-4"></h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-x-8 gap-y-4 text-sm">
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span>{member.birthDate || "-"}</span>
{member.birthDate && member.isLunarDate && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200">
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span>{member.deathDate || "-"}</span>
{member.deathDate && member.isLunarDate && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200">
</span>
)}
</div>
</dd>
</div>
{ageInfo && (
<div>
<dt className="text-muted-foreground mb-1">{ageInfo.type === 'deceased' ? '享年' : '年龄'}</dt>
<dd className="font-medium text-primary">{ageInfo.age} </dd>
</div>
)}
{member.deathDate && (
<>
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.posthumousName || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.burialPlace || "-"}</dd>
</div>
</>
)}
{member.generationName && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.generationName}</dd>
</div>
)}
{member.courtesyName && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.courtesyName}</dd>
</div>
)}
{member.artName && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.artName}</dd>
</div>
)}
{member.ancestralHome && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.ancestralHome}</dd>
</div>
)}
{member.birthPlace && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.birthPlace}</dd>
</div>
)}
{member.address && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.address}</dd>
</div>
)}
{member.phone && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">
<a href={`tel:${member.phone}`} className="hover:text-primary">
{member.phone}
</a>
</dd>
</div>
)}
{member.telephone && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">
<a href={`tel:${member.telephone}`} className="hover:text-primary">
{member.telephone}
</a>
</dd>
</div>
)}
{member.email && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">
<a href={`mailto:${member.email}`} className="hover:text-primary">
{member.email}
</a>
</dd>
</div>
)}
</dl>
</div>
{member.tags && member.tags.length > 0 && (
<div className={`bg-card rounded-lg ${founderBorderClass} p-6 shadow-sm`}>
<h3 className="text-lg font-serif font-semibold mb-4"></h3>
<div className="flex flex-wrap gap-2">
{member.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-md border border-primary/20"
>
{tag}
</span>
))}
</div>
</div>
)}
</TabsContent>
<TabsContent value="life" className="mt-6 space-y-6">
{/* 生平事迹 */}
<div className={`prose prose-stone dark:prose-invert max-w-none bg-card p-6 rounded-lg ${founderBorderClass} shadow-sm w-full`}>
<h3 className="text-lg font-serif font-semibold mb-4"></h3>
{member.bio ? (
<p className="whitespace-pre-line leading-relaxed">{member.bio}</p>
) : (
<p className="text-sm text-muted-foreground text-center py-10"></p>
)}
</div>
{/* 影像记忆 */}
<div className={`bg-card p-6 rounded-lg ${founderBorderClass} shadow-sm w-full`}>
<h3 className="text-lg font-serif font-semibold mb-4 flex items-center gap-2">
<Images className="h-5 w-5 text-primary" />
</h3>
{member.photoIds && member.photoIds.length > 0 ? (
<PhotoGallery
photoIds={member.photoIds}
onChange={async (photoIds) => {
await updateMember(member.id, { photoIds })
setLastSaveTime(Date.now())
}}
readonly={false}
/>
) : (
<div className="text-sm text-muted-foreground text-center py-10">
</div>
)}
</div>
</TabsContent>
<TabsContent value="stories" className="mt-6 space-y-6">
<div className={`bg-card p-6 rounded-lg ${founderBorderClass} shadow-sm w-full`}>
<h3 className="text-lg font-serif font-semibold mb-4 flex items-center gap-2">
<BookOpen className="h-5 w-5 text-primary" />
</h3>
{member.stories && member.stories.length > 0 ? (
<StoryManager
stories={member.stories}
onChange={async (stories) => {
await updateMember(member.id, { stories })
setLastSaveTime(Date.now())
}}
readonly={false}
/>
) : (
<div className="text-sm text-muted-foreground text-center py-10">
</div>
)}
</div>
</TabsContent>
<TabsContent value="history" className="mt-6 space-y-6">
<div className={`bg-card rounded-lg ${founderBorderClass} p-6 shadow-sm w-full`}>
<h3 className="text-lg font-serif font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground">
{new Date(lastSaveTime).toLocaleString('zh-CN')}
</p>
</div>
<div className={`bg-card rounded-lg ${founderBorderClass} p-6 shadow-sm w-full`}>
<h3 className="text-lg font-serif font-semibold mb-4"></h3>
<MemberVersionHistory
memberId={member.id}
treeId={currentTree?.id || ''}
lastUpdated={lastSaveTime}
/>
</div>
</TabsContent>
</Tabs>
</main>
</div>
)
}