364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import Link from "next/link"
|
|
import { BookOpen, Search, Bell, Settings, LogOut, User, ChevronDown, Plus, Trash2 } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { useFamily } from "@/context/family-context"
|
|
import { useState, useRef, useEffect, useCallback } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { useSession, signOut } from "next-auth/react"
|
|
|
|
interface FamilyTree {
|
|
id: string
|
|
name: string
|
|
description: string | null
|
|
ownerId: string
|
|
}
|
|
|
|
export function SiteHeader() {
|
|
const { searchMembers, searchResults } = useFamily()
|
|
const { data: session, status } = useSession()
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [showResults, setShowResults] = useState(false)
|
|
const [familyTrees, setFamilyTrees] = useState<FamilyTree[]>([])
|
|
const [currentTree, setCurrentTree] = useState<FamilyTree | null>(null)
|
|
const [deletingTreeId, setDeletingTreeId] = useState<string | null>(null)
|
|
const searchRef = useRef<HTMLDivElement>(null)
|
|
const router = useRouter()
|
|
|
|
const handleSignOut = async () => {
|
|
await signOut({ callbackUrl: "/auth/signin" })
|
|
}
|
|
|
|
// 获取用户名首字母作为头像
|
|
const getUserInitial = () => {
|
|
if (session?.user?.name) {
|
|
return session.user.name.charAt(0).toUpperCase()
|
|
}
|
|
if (session?.user?.email) {
|
|
return session.user.email.charAt(0).toUpperCase()
|
|
}
|
|
return "U"
|
|
}
|
|
|
|
// 删除家族树
|
|
const handleDeleteTree = async (tree: FamilyTree, e: React.MouseEvent) => {
|
|
e.stopPropagation() // 阻止触发选择事件
|
|
|
|
// 检查权限
|
|
if (tree.ownerId !== session?.user?.id) {
|
|
alert('只有家族树的创建者才能删除')
|
|
return
|
|
}
|
|
|
|
const confirmText = `确定要删除家族树"${tree.name}"吗?\n\n此操作将永久删除所有成员、活动日志和协作者数据,且不可撤销!`
|
|
if (!confirm(confirmText)) {
|
|
return
|
|
}
|
|
|
|
// 二次确认
|
|
const finalConfirm = prompt(`请输入家族树名称"${tree.name}"以确认删除:`)
|
|
if (finalConfirm !== tree.name) {
|
|
alert('名称不匹配,删除已取消')
|
|
return
|
|
}
|
|
|
|
setDeletingTreeId(tree.id)
|
|
try {
|
|
const response = await fetch(`/api/trees/${tree.id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || '删除失败')
|
|
}
|
|
|
|
// 刷新家族树列表
|
|
await loadFamilyTrees()
|
|
|
|
// 如果删除的是当前树,跳转到首页
|
|
if (currentTree?.id === tree.id) {
|
|
window.location.href = '/'
|
|
}
|
|
} catch (error) {
|
|
console.error('删除家族树失败:', error)
|
|
alert('删除失败:' + (error as Error).message)
|
|
} finally {
|
|
setDeletingTreeId(null)
|
|
}
|
|
}
|
|
|
|
// 获取家族树列表
|
|
const loadFamilyTrees = useCallback(() => {
|
|
if (session?.user?.id) {
|
|
fetch('/api/trees')
|
|
.then(res => {
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`)
|
|
}
|
|
return res.json()
|
|
})
|
|
.then(data => {
|
|
if (data.trees) {
|
|
setFamilyTrees(data.trees)
|
|
// 如果 URL 中有 treeId,使用该树,否则使用第一个
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const treeId = urlParams.get('treeId')
|
|
if (treeId) {
|
|
const tree = data.trees.find((t: FamilyTree) => t.id === treeId)
|
|
if (tree) {
|
|
setCurrentTree(tree)
|
|
} else if (data.trees.length > 0) {
|
|
setCurrentTree(data.trees[0])
|
|
}
|
|
} else if (data.trees.length > 0) {
|
|
setCurrentTree(data.trees[0])
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('获取家族树列表失败:', err)
|
|
})
|
|
}
|
|
}, [session?.user?.id])
|
|
|
|
useEffect(() => {
|
|
loadFamilyTrees()
|
|
}, [loadFamilyTrees])
|
|
|
|
// 监听路由变化,刷新家族树列表
|
|
useEffect(() => {
|
|
const handleRouteChange = () => {
|
|
loadFamilyTrees()
|
|
}
|
|
|
|
window.addEventListener('focus', handleRouteChange)
|
|
return () => window.removeEventListener('focus', handleRouteChange)
|
|
}, [loadFamilyTrees])
|
|
|
|
// 点击外部关闭搜索结果
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
|
setShowResults(false)
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}, [])
|
|
|
|
const handleSearch = (query: string) => {
|
|
setSearchQuery(query)
|
|
searchMembers(query)
|
|
setShowResults(query.length > 0)
|
|
}
|
|
|
|
const handleSelectMember = (memberId: string) => {
|
|
router.push(`/members/${memberId}`)
|
|
setSearchQuery("")
|
|
setShowResults(false)
|
|
}
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<div className="container mx-auto flex h-16 items-center px-4">
|
|
<div className="mr-8 flex items-center gap-4">
|
|
<Link href="/" className="flex items-center gap-2">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded bg-primary text-primary-foreground">
|
|
<BookOpen className="h-5 w-5" />
|
|
</div>
|
|
<span className="text-xl font-serif font-bold tracking-tight">华夏谱</span>
|
|
</Link>
|
|
|
|
{/* 家族树选择器 */}
|
|
{session && familyTrees.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="gap-2 min-w-[200px]">
|
|
<span className="flex-1 truncate text-left">{currentTree?.name || '选择家族树'}</span>
|
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-[200px]">
|
|
<DropdownMenuLabel>我的家族树</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{familyTrees.map((tree) => (
|
|
<DropdownMenuItem
|
|
key={tree.id}
|
|
onClick={() => {
|
|
setCurrentTree(tree)
|
|
router.push(`/?treeId=${tree.id}`)
|
|
}}
|
|
className="cursor-pointer flex items-center justify-between group"
|
|
>
|
|
<span className="flex-1 truncate">{tree.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
{currentTree?.id === tree.id && (
|
|
<span className="text-primary">✓</span>
|
|
)}
|
|
{tree.ownerId === session?.user?.id && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive hover:text-destructive-foreground"
|
|
onClick={(e) => handleDeleteTree(tree, e)}
|
|
disabled={deletingTreeId === tree.id}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => router.push('/trees/new')}
|
|
className="cursor-pointer text-primary"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
创建新家族树
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
|
|
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
|
|
<Link
|
|
href="/"
|
|
className="transition-colors hover:text-foreground data-[active=true]:text-foreground data-[active=true]:font-semibold"
|
|
>
|
|
总览 (Overview)
|
|
</Link>
|
|
<Link href="/tree" className="transition-colors hover:text-foreground">
|
|
族谱 (Tree)
|
|
</Link>
|
|
<Link href="/members" className="transition-colors hover:text-foreground">
|
|
成员 (Members)
|
|
</Link>
|
|
<Link href="/timeline" className="transition-colors hover:text-foreground">
|
|
大事记 (Timeline)
|
|
</Link>
|
|
</nav>
|
|
|
|
<div className="ml-auto flex items-center gap-4">
|
|
<div className="relative hidden sm:block" ref={searchRef}>
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="search"
|
|
placeholder="搜索成员..."
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="h-9 w-64 rounded-full bg-muted pl-9 text-sm focus-visible:ring-primary"
|
|
/>
|
|
|
|
{/* 搜索结果下拉框 */}
|
|
{showResults && searchResults.length > 0 && (
|
|
<div className="absolute top-full mt-2 w-full bg-background border border-border rounded-lg shadow-lg max-h-96 overflow-y-auto z-50">
|
|
{searchResults.map((member) => (
|
|
<button
|
|
key={member.id}
|
|
onClick={() => handleSelectMember(member.id)}
|
|
className="w-full px-4 py-3 text-left hover:bg-muted transition-colors border-b border-border last:border-0"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{member.fullName}</span>
|
|
<span className={`text-xs px-1.5 py-0.5 rounded ${member.gender === "male" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}>
|
|
{member.gender === "male" ? "男" : "女"}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">第{member.generation}世</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{member.birthDate && `生于 ${member.birthDate}`}
|
|
{member.birthPlace && ` · ${member.birthPlace}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 无结果提示 */}
|
|
{showResults && searchQuery && searchResults.length === 0 && (
|
|
<div className="absolute top-full mt-2 w-full bg-background border border-border rounded-lg shadow-lg p-4 text-center text-sm text-muted-foreground z-50">
|
|
未找到匹配的成员
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
|
<Bell className="h-5 w-5" />
|
|
<span className="sr-only">Notifications</span>
|
|
</Button>
|
|
|
|
<Link href="/settings">
|
|
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
|
<Settings className="h-5 w-5" />
|
|
<span className="sr-only">Settings</span>
|
|
</Button>
|
|
</Link>
|
|
|
|
{status === "loading" ? (
|
|
<Avatar className="h-8 w-8 border border-border">
|
|
<AvatarFallback>...</AvatarFallback>
|
|
</Avatar>
|
|
) : session ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
|
<Avatar className="h-8 w-8 border border-border">
|
|
<AvatarImage src={undefined} alt={session.user?.name || "User"} />
|
|
<AvatarFallback>{getUserInitial()}</AvatarFallback>
|
|
</Avatar>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex flex-col space-y-1">
|
|
<p className="text-sm font-medium leading-none">{session.user?.name || "用户"}</p>
|
|
<p className="text-xs leading-none text-muted-foreground">
|
|
{session.user?.email}
|
|
</p>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings" className="cursor-pointer">
|
|
<User className="mr-2 h-4 w-4" />
|
|
<span>个人设置</span>
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer text-red-600">
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>退出登录</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<Link href="/auth/signin">
|
|
<Button variant="default" size="sm">
|
|
登录
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|