Files
chinese-family-tree-2/components/site-header.tsx
T
freedakgmail 3d075c6076 0.0.8.5
2025-11-24 14:02:34 +08:00

493 lines
22 KiB
TypeScript

"use client"
import Link from "next/link"
import { BookOpen, Search, Settings, LogOut, User, ChevronDown, Plus, Trash2, Network, LayoutGrid, Calculator, HelpCircle, Calendar } 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, usePathname, useSearchParams } from "next/navigation"
import { useSession, signOut } from "next-auth/react"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import { permissions } from "@/lib/permissions"
import { solar2lunar } from "@/lib/lunar-calendar"
import { CalendarDialog } from "@/components/calendar-dialog"
import { MemberNameWithStatus } from "./member-name-with-status"
interface FamilyTree {
id: string
name: string
description: string | null
ownerId: string
currentUserRole?: "OWNER" | "EDITOR" | "VIEWER"
}
export function SiteHeader() {
const { searchMembers, searchResults, currentTree: familyCurrentTree } = useFamily()
const { data: session, status } = useSession()
const { showAlert, showConfirm, showPrompt } = useDialog()
const pathname = usePathname()
const searchParams = useSearchParams()
const [searchQuery, setSearchQuery] = useState("")
const [showResults, setShowResults] = useState(false)
const [familyTrees, setFamilyTrees] = useState<FamilyTree[]>([])
const [deletingTreeId, setDeletingTreeId] = useState<string | null>(null)
const [treeDropdownOpen, setTreeDropdownOpen] = useState(false)
const [currentDate, setCurrentDate] = useState(new Date())
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
const searchRef = useRef<HTMLDivElement>(null)
const router = useRouter()
// 使用 family context 的 currentTree
const currentTree = familyCurrentTree
// 获取当前视图模式
const currentViewMode = searchParams.get('view') || 'traditional'
const isTreePage = pathname === '/tree'
// 更新日期
useEffect(() => {
const timer = setInterval(() => {
setCurrentDate(new Date())
}, 60000) // 每分钟更新一次
return () => clearInterval(timer)
}, [])
// 获取农历信息
const lunarInfo = solar2lunar(currentDate)
const solarDateStr = `${currentDate.getFullYear()}${currentDate.getMonth() + 1}${currentDate.getDate()}`
const lunarDateStr = lunarInfo ? lunarInfo.toString() : '农历信息不可用'
// 生成带有 treeId 的 URL
const getUrlWithTreeId = (path: string) => {
if (currentTree?.id) {
return `${path}?treeId=${currentTree.id}`
}
return path
}
// 切换视图模式
const handleViewModeChange = (mode: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('view', mode)
if (currentTree?.id) {
params.set('treeId', currentTree.id)
}
router.push(`${pathname}?${params.toString()}`)
}
const getRoleBadge = (role?: string) => {
switch (role) {
case "OWNER":
return <span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 border border-purple-200 ml-2"></span>
case "EDITOR":
return <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 ml-2"></span>
case "VIEWER":
return <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 ml-2"></span>
default:
return null
}
}
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) {
await showAlert('只有家族树的创建者才能删除', '权限不足')
return
}
const confirmText = `确定要删除家族树"${tree.name}"吗?\n\n此操作将永久删除所有成员、活动日志和协作者数据,且不可撤销!`
const confirmed = await showConfirm(confirmText, '删除家族树', 'destructive')
if (!confirmed) {
return
}
// 二次确认
const finalConfirm = await showPrompt(
`请输入家族树名称"${tree.name}"以确认删除:`,
'',
'确认删除'
)
if (finalConfirm !== tree.name) {
await showAlert('名称不匹配,删除已取消', '操作已取消')
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)
await showAlert('删除失败:' + (error as Error).message, '错误')
} finally {
setDeletingTreeId(null)
}
}
// 获取家族树列表
const loadFamilyTrees = useCallback(() => {
if (!session?.user?.id || status !== 'authenticated') return
const controller = new AbortController()
fetch('/api/trees', { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
return res.json()
})
.then(data => {
if (data.trees) {
setFamilyTrees(data.trees)
}
})
.catch(err => {
// 忽略 abort 错误和 fetch 错误(可能是页面切换导致的)
if (err.name !== 'AbortError' && err.message !== 'Failed to fetch') {
console.error('获取家族树列表失败:', err)
}
})
return () => controller.abort()
}, [session?.user?.id, status])
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) => {
const params = new URLSearchParams(window.location.search)
router.push(`/members/${memberId}?${params.toString()}`)
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-2">
<Link href={getUrlWithTreeId("/")} 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>
</div>
<nav className="hidden md:flex items-center gap-3 font-serif" onClick={() => setTreeDropdownOpen(false)}>
<Link
href={getUrlWithTreeId("/")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(1% 0%, 99% 1%, 100% 98%, 2% 99%, 0% 2%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(0.5% 1%, 98.5% 0.5%, 99.5% 99%, 1.5% 98.5%, 1% 1.5%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
<Link
href={getUrlWithTreeId("/tree")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(0% 1%, 99% 0%, 100% 99%, 1% 100%, 1% 1%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(1% 0.5%, 98% 1%, 99% 98.5%, 2% 99.5%, 0.5% 2%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
<Link
href={getUrlWithTreeId("/members")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(1% 1%, 98% 0%, 99% 99%, 0% 98%, 2% 2%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(0.5% 0%, 99% 1.5%, 98.5% 99%, 1% 98%, 1.5% 1%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
<Link
href={getUrlWithTreeId("/timeline")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(0% 0%, 100% 1%, 99% 100%, 1% 99%, 0.5% 1.5%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(1.5% 1%, 98.5% 0%, 99.5% 98%, 0.5% 99.5%, 1% 2%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
</nav>
{/* 家族树选择器 */}
{session && familyTrees.length > 0 && (
<div className="ml-4 flex-shrink-0">
<DropdownMenu open={treeDropdownOpen} onOpenChange={setTreeDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 w-[180px] font-serif">
<span className="flex-1 truncate text-left">{currentTree?.name || '选择家族树'}</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[300px] font-serif"
sideOffset={5}
avoidCollisions={false}
sticky="always"
>
<DropdownMenuLabel className="font-light"></DropdownMenuLabel>
<DropdownMenuSeparator />
{familyTrees.map((tree) => (
<DropdownMenuItem
key={tree.id}
onClick={(e) => {
e.preventDefault()
setTreeDropdownOpen(false)
// 切换家族后跳转到总览页面,family-context 会自动加载新的树
router.push(`/?treeId=${tree.id}`)
}}
className="cursor-pointer flex items-center justify-between group py-3"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate flex-1">{tree.name}</span>
{getRoleBadge(tree.currentUserRole)}
{currentTree?.id === tree.id && (
<span className="text-primary flex-shrink-0"></span>
)}
</div>
{permissions.canDeleteTree(tree.currentUserRole) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleDeleteTree(tree, e)}
disabled={deletingTreeId === tree.id}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{session && (
<DropdownMenuItem
onClick={() => router.push('/trees/new')}
className="gap-2"
>
<Plus className="h-4 w-4" />
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<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">
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
className="font-medium"
/>
<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
onClick={() => setCalendarDialogOpen(true)}
className="hidden lg:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border border-red-200/50 shadow-sm hover:shadow-md hover:from-red-100 hover:to-amber-100 transition-all cursor-pointer"
title="点击查询公历农历"
>
<Calendar className="h-4 w-4 text-red-600" />
<div className="flex flex-col text-xs leading-tight">
<span className="font-medium text-gray-900">{solarDateStr}</span>
<span className="text-red-700 font-serif">{lunarDateStr}</span>
</div>
</button>
<Link href={getUrlWithTreeId("/relationship")}>
<Button variant="ghost" size="icon" className="text-muted-foreground">
<Calculator className="h-5 w-5" />
<span className="sr-only">Relationship Calculator</span>
</Button>
</Link>
<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>
<Link href="/help">
<Button variant="ghost" size="icon" className="text-muted-foreground">
<HelpCircle className="h-5 w-5" />
<span className="sr-only">Help</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 font-serif" 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={getUrlWithTreeId("/settings")} className="cursor-pointer">
<Settings 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>
{/* 日历查询对话框 - 放在 header 外部避免定位冲突 */}
<CalendarDialog open={calendarDialogOpen} onOpenChange={setCalendarDialogOpen} />
</>
)
}