This commit is contained in:
freedakgmail
2025-11-24 16:07:24 +08:00
parent 3d075c6076
commit b25f4bea02
426 changed files with 3619 additions and 4468 deletions
-1
View File
@@ -37,7 +37,6 @@ const createMemberSchema = z.object({
bio: z.string().optional(),
tags: z.array(z.string()).optional(),
avatarUrl: z.string().optional(),
avatarImageId: z.string().optional(),
photoIds: z.array(z.string()).optional(),
})
+4 -4
View File
@@ -24,19 +24,19 @@ export const metadata: Metadata = {
icons: {
icon: [
{
url: "/icon-light-32x32.png",
url: "/icon-light-32x32.png?t=" + Date.now(),
media: "(prefers-color-scheme: light)",
},
{
url: "/icon-dark-32x32.png",
url: "/icon-dark-32x32.png?t=" + Date.now(),
media: "(prefers-color-scheme: dark)",
},
{
url: "/icon.svg",
url: "/icon.svg?t=" + Date.now(),
type: "image/svg+xml",
},
],
apple: "/apple-icon.png",
apple: "/apple-icon.png?t=" + Date.now(),
},
}
+10 -13
View File
@@ -9,13 +9,13 @@ import { MemberForm } from "@/components/members/member-form"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Edit, Trash, Share2, Printer, CircleUser, CircleUserRound, Images, BookOpen, UserPlus, Camera } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import { db } from "@/lib/db"
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()
@@ -23,9 +23,17 @@ export default function MemberProfilePage() {
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)
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const member = getMember(id as string)
const allMembers = Object.values(treeData.members)
@@ -54,23 +62,12 @@ export default function MemberProfilePage() {
// Load avatar
useEffect(() => {
// 优先使用 avatarUrl(服务器存储)
if (member?.avatarUrl) {
setAvatarBlobUrl(member.avatarUrl)
}
// 兼容旧的 IndexedDB 存储
else if (member?.avatarImageId) {
db.images.get(member.avatarImageId).then((image) => {
if (image) {
const url = URL.createObjectURL(image.blob)
setAvatarBlobUrl(url)
return () => URL.revokeObjectURL(url)
}
})
} else {
setAvatarBlobUrl(undefined)
}
}, [member?.avatarUrl, member?.avatarImageId])
}, [member?.avatarUrl])
// 显示加载状态
if (isLoading) {
+10 -1
View File
@@ -7,12 +7,21 @@ import { MemberForm } from "@/components/members/member-form"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import { useMemo } from "react"
import { useMemo, useEffect } from "react"
import { useSession } from "next-auth/react"
export default function NewMemberPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { addMember, treeData, getMember } = useFamily()
const { data: session, status } = useSession()
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const existingMembers = Object.values(treeData.members)
const isFirstMember = existingMembers.length === 0
+17 -2
View File
@@ -7,7 +7,7 @@ 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 { 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"
@@ -18,6 +18,8 @@ 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 {
@@ -92,7 +94,7 @@ const MemberCard = memo(function MemberCard({
? '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-5">
<CardContent className="px-3 py-2 flex items-center gap-5">
<div className="relative flex-shrink-0">
<AvatarDisplay
imageUrl={member.avatarUrl}
@@ -198,10 +200,18 @@ const MemberCard = memo(function MemberCard({
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)
@@ -365,6 +375,11 @@ export default function MembersPage() {
(statusFilter !== "all" ? 1 : 0) +
(generationRange[0] > 1 || generationRange[1] < 100 ? 1 : 0)
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
+61 -49
View File
@@ -387,53 +387,65 @@ export default function DashboardPage() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<StatCard
title="家族成员"
value={stats.totalMembers.toString()}
subtitle={`${stats.maleCount} · 女 ${stats.femaleCount}`}
icon={<Users className="h-4 w-4" />}
href={currentTree?.id ? `/members?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="记录年代"
value={`${stats.yearsSpan}`}
subtitle={`始于 ${stats.earliestYear}`}
icon={<Clock className="h-4 w-4" />}
href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="繁衍代数"
value={`${stats.maxGeneration}`}
subtitle={`当前最高世代`}
icon={<Network className="h-4 w-4" />}
href={currentTree?.id ? `/tree?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="在世 / 已故"
value={`${stats.livingMembers} / ${stats.deceasedMembers}`}
subtitle={`在世率 ${stats.totalMembers > 0 ? Math.round(stats.livingMembers / stats.totalMembers * 100) : 0}%`}
icon={<Users className="h-4 w-4" />}
href={currentTree?.id ? `/members?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="平均寿命"
value={stats.averageLifespan > 0 ? `${stats.averageLifespan}` : '暂无数据'}
subtitle={`已统计 ${stats.deceasedMembers}`}
icon={<Clock className="h-4 w-4" />}
href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : undefined}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{/* 第一个卡片:家族成员 + 在世/已故 */}
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<Users className="h-4 w-4" />
<Users className="h-5 w-5" />
</div>
</div>
<div className="flex flex-col space-y-1">
<div className="flex items-center justify-center gap-3">
<div className="flex flex-col space-y-1.5">
<span className="text-3xl font-serif font-bold text-foreground tracking-tight">{stats.totalMembers} </span>
<div className="flex items-center gap-3 text-sm text-muted-foreground/80 font-medium">
<span className="text-green-600"> {stats.livingMembers}</span>
<span className="text-muted-foreground">·</span>
<span className="text-gray-600"> {stats.deceasedMembers}</span>
</div>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
</Link>
{/* 第二个卡片:繁衍代数 + 记录年代 + 平均寿命 */}
<Link href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : '#'}>
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<Clock className="h-5 w-5" />
</div>
</div>
<div className="flex flex-col space-y-1.5">
<span className="text-3xl font-serif font-bold text-foreground tracking-tight">{stats.maxGeneration} </span>
<div className="flex items-center gap-3 text-sm text-muted-foreground/80 font-medium">
<span> {stats.yearsSpan} </span>
<span className="text-muted-foreground">·</span>
<span>寿 {stats.averageLifespan > 0 ? `${stats.averageLifespan}` : '-'}</span>
</div>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
</Link>
{/* 第三个卡片:性别比例 */}
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<Users className="h-5 w-5" />
</div>
</div>
<div className="flex flex-col space-y-1.5">
<div className="flex items-center justify-center gap-2.5">
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? '1' : '0'}
</span>
@@ -442,7 +454,7 @@ export default function DashboardPage() {
{stats.maleCount > 0 ? (stats.femaleCount / stats.maleCount).toFixed(2) : '0'}
</span>
</div>
<div className="text-center text-xs text-muted-foreground/80 font-medium mt-1">
<div className="text-center text-sm text-muted-foreground/80 font-medium">
{stats.maleCount} · {stats.femaleCount}
</div>
</div>
@@ -869,15 +881,15 @@ function StatCard({
}: { title: string; value: string; subtitle: string; icon: React.ReactNode; href?: string }) {
const cardContent = (
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{title}</p>
<div className="text-primary/70 bg-primary/10 p-2 rounded-lg group-hover:bg-primary/20 transition-colors">
{icon}
</div>
</div>
<div className="flex flex-col space-y-1">
<span className="text-3xl font-serif font-bold text-foreground tracking-tight">{value}</span>
<div className="flex flex-col space-y-0.5">
<span className="text-2xl font-serif font-bold text-foreground tracking-tight">{value}</span>
<p className="text-xs text-muted-foreground/80 font-medium">{subtitle}</p>
</div>
</CardContent>
+70 -38
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { SiteHeader } from '@/components/site-header'
import { useFamily } from '@/context/family-context'
import { useRelationship } from '@/hooks/use-relationship'
@@ -9,14 +9,19 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Check, ChevronsUpDown, Users, User } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MemberNameWithStatus } from '@/components/member-name-with-status'
import { RelationshipPathDisplay } from '@/components/relationship-path-display'
import { useSession } from 'next-auth/react'
import { EmptyStateBadge } from '@/components/empty-state-badge'
import { useRouter } from 'next/navigation'
export default function RelationshipTestPage() {
const { treeData } = useFamily()
const { calculateRelationship, getDirectRelatives, getSiblings } = useRelationship()
const { data: session, status } = useSession()
const router = useRouter()
const [fromId, setFromId] = useState<string>('')
const [toId, setToId] = useState<string>('')
@@ -24,6 +29,13 @@ export default function RelationshipTestPage() {
const [openFromCombobox, setOpenFromCombobox] = useState(false)
const [openToCombobox, setOpenToCombobox] = useState(false)
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const members = Object.values(treeData.members)
const handleCalculate = () => {
@@ -40,28 +52,48 @@ export default function RelationshipTestPage() {
})
}
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
// 无数据时显示空状态
if (members.length === 0) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<main className="flex-1 flex items-center justify-center">
<EmptyStateBadge
icon={!session ? User : Users}
text={!session ? '请先登录' : '暂无成员'}
/>
</main>
</div>
)
}
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<main className="flex-1 container mx-auto px-4 py-12">
<div className="max-w-4xl mx-auto space-y-8">
<main className="flex-1 container mx-auto px-4 py-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* 标题 */}
<div>
<h1 className="text-3xl font-serif font-bold mb-2"></h1>
<p className="text-muted-foreground">
<h1 className="text-2xl font-serif font-bold mb-1"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
{/* 选择成员 */}
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-serif font-light"></CardTitle>
<CardDescription className="font-light"></CardDescription>
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl font-serif font-light"></CardTitle>
<CardDescription className="text-sm font-light"></CardDescription>
</CardHeader>
<CardContent className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">A</label>
<Popover open={openFromCombobox} onOpenChange={setOpenFromCombobox}>
@@ -182,7 +214,7 @@ export default function RelationshipTestPage() {
<Button
onClick={handleCalculate}
disabled={!fromId || !toId}
className="w-full h-12 bg-primary text-primary-foreground hover:bg-primary/90"
className="w-full h-10 bg-primary text-primary-foreground hover:bg-primary/90"
>
</Button>
@@ -192,13 +224,13 @@ export default function RelationshipTestPage() {
{/* 计算结果 */}
{result && (
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-serif font-light"></CardTitle>
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl font-serif font-light"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-center gap-6 p-8 bg-muted/30 rounded-lg">
<CardContent className="space-y-3">
<div className="flex items-center justify-center gap-4 p-5 bg-muted/30 rounded-lg">
<div className="text-center">
<div className="text-2xl font-serif font-light mb-1">
<div className="text-xl font-serif font-light mb-1">
<MemberNameWithStatus
name={result.from.fullName}
isDead={!!result.from.deathDate}
@@ -209,8 +241,8 @@ export default function RelationshipTestPage() {
</div>
</div>
<div className="flex flex-col items-center gap-2">
<Badge className="text-lg px-6 py-2 bg-primary text-primary-foreground">
<div className="flex flex-col items-center gap-1.5">
<Badge className="text-base px-5 py-1.5 bg-primary text-primary-foreground">
{result.relationship.term}
</Badge>
{result.relationship.path && (
@@ -222,7 +254,7 @@ export default function RelationshipTestPage() {
</div>
<div className="text-center">
<div className="text-2xl font-serif font-light mb-1">
<div className="text-xl font-serif font-light mb-1">
<MemberNameWithStatus
name={result.to.fullName}
isDead={!!result.to.deathDate}
@@ -235,7 +267,7 @@ export default function RelationshipTestPage() {
</div>
{result.relationship.description && (
<div className="p-6 bg-primary/5 rounded-lg">
<div className="p-4 bg-primary/5 rounded-lg">
<div className="text-sm font-light mb-2 text-center"></div>
<div className="text-muted-foreground font-light text-center">
<strong><MemberNameWithStatus name={result.from.fullName} isDead={!!result.from.deathDate} /></strong> <strong><MemberNameWithStatus name={result.to.fullName} isDead={!!result.to.deathDate} /></strong> <strong className="text-primary">{result.relationship.term}</strong>
@@ -256,11 +288,11 @@ export default function RelationshipTestPage() {
{/* 直系亲属信息 */}
{(fromId || toId) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{fromId && (
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="text-xl font-serif font-light">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-serif font-light">
<MemberNameWithStatus
name={treeData.members[fromId]?.fullName || ''}
isDead={!!treeData.members[fromId]?.deathDate}
@@ -274,9 +306,9 @@ export default function RelationshipTestPage() {
return (
<>
<div className="grid grid-cols-1 gap-3">
<div className="grid grid-cols-1 gap-2">
{relatives.father && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">
<MemberNameWithStatus
@@ -287,7 +319,7 @@ export default function RelationshipTestPage() {
</div>
)}
{relatives.mother && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">
<MemberNameWithStatus
@@ -298,7 +330,7 @@ export default function RelationshipTestPage() {
</div>
)}
{relatives.spouses.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
{relatives.spouses.map((spouse: any) => (
<div key={spouse.id} className="font-medium">
@@ -311,7 +343,7 @@ export default function RelationshipTestPage() {
</div>
)}
{relatives.children.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1">
({relatives.children.length})
</div>
@@ -339,7 +371,7 @@ export default function RelationshipTestPage() {
if (siblings.length === 0) return null
return (
<div className="mt-3 p-3 bg-muted rounded-lg">
<div className="mt-2 p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2">
({siblings.length})
</div>
@@ -365,8 +397,8 @@ export default function RelationshipTestPage() {
{toId && (
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="text-xl font-serif font-light">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-serif font-light">
{treeData.members[toId]?.fullName}
</CardTitle>
</CardHeader>
@@ -377,9 +409,9 @@ export default function RelationshipTestPage() {
return (
<>
<div className="grid grid-cols-1 gap-3">
<div className="grid grid-cols-1 gap-2">
{relatives.father && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">
<MemberNameWithStatus
@@ -390,7 +422,7 @@ export default function RelationshipTestPage() {
</div>
)}
{relatives.mother && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">
<MemberNameWithStatus
@@ -401,7 +433,7 @@ export default function RelationshipTestPage() {
</div>
)}
{relatives.spouses.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
{relatives.spouses.map((spouse: any) => (
<div key={spouse.id} className="font-medium">
@@ -414,7 +446,7 @@ export default function RelationshipTestPage() {
</div>
)}
{relatives.children.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1">
({relatives.children.length})
</div>
@@ -442,7 +474,7 @@ export default function RelationshipTestPage() {
if (siblings.length === 0) return null
return (
<div className="mt-3 p-3 bg-muted rounded-lg">
<div className="mt-2 p-2.5 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2">
({siblings.length})
</div>
+17 -2
View File
@@ -4,7 +4,7 @@ import { SiteHeader } from "@/components/site-header"
import { useFamily } from "@/context/family-context"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Database, User as UserIcon } from "lucide-react"
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Database, User as UserIcon, Settings, User } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { useRef, useState, useEffect } from "react"
import { exportToGedcom, importFromGedcom } from "@/lib/gedcom"
@@ -15,6 +15,8 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ChangePassword } from "@/components/settings/change-password"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import { permissions } from "@/lib/permissions"
import { EmptyStateBadge } from "@/components/empty-state-badge"
import { useRouter } from "next/navigation"
interface UserProfile {
id: string
@@ -33,14 +35,22 @@ interface UserProfile {
export default function SettingsPage() {
const { treeData, currentTree, loadData, resetData } = useFamily()
const role = currentTree?.currentUserRole
const { data: session } = useSession()
const { data: session, status } = useSession()
const { showAlert, showConfirm } = useDialog()
const router = useRouter()
const [importStatus, setImportStatus] = useState<string>("")
const [gedcomStatus, setGedcomStatus] = useState<string>("")
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const gedcomInputRef = useRef<HTMLInputElement>(null)
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const getUserInitial = () => {
if (session?.user?.name) {
return session.user.name.charAt(0).toUpperCase()
@@ -83,6 +93,11 @@ export default function SettingsPage() {
return colorMap[role] || 'bg-gray-100 text-gray-700'
}
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
+18 -1
View File
@@ -4,11 +4,14 @@ import { useFamily } from "@/context/family-context"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Clock, Map as MapIcon, ArrowRight, Calendar } from "lucide-react"
import { Clock, Map as MapIcon, ArrowRight, Calendar, User } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useMemo, useEffect, useState } from 'react'
import * as echarts from 'echarts'
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { useSession } from 'next-auth/react'
import { EmptyStateBadge } from '@/components/empty-state-badge'
import { useRouter } from 'next/navigation'
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false })
@@ -186,9 +189,18 @@ const geoCoordMap: Record<string, [number, number]> = {
export default function TimelinePage() {
const { treeData, currentTree, getMember, isLoading } = useFamily()
const { data: session, status } = useSession()
const members = Object.values(treeData.members)
const [mapLoaded, setMapLoaded] = useState(false)
const treeId = currentTree?.id
const router = useRouter()
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
// 注册中国地图 - 从外部文件加载完整数据(必须在条件渲染之前)
useEffect(() => {
@@ -470,6 +482,11 @@ export default function TimelinePage() {
}
}, [migrations])
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
// 显示加载状态
if (isLoading) {
return (
+16 -1
View File
@@ -16,17 +16,20 @@ import {
} from "@/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users, Network } from "lucide-react"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users, Network, User } from "lucide-react"
import { useState, useRef, useEffect, useCallback } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { RelationshipPathDisplay } from "@/components/relationship-path-display"
import { useSession } from "next-auth/react"
import { EmptyStateBadge } from "@/components/empty-state-badge"
type ViewMode = "traditional" | "d3-org-chart"
export default function TreePage() {
const { treeData } = useFamily()
const { calculateRelationship } = useRelationship()
const { data: session, status } = useSession()
const router = useRouter()
const searchParams = useSearchParams()
const [viewMode, setViewMode] = useState<ViewMode>("traditional")
@@ -46,6 +49,13 @@ export default function TreePage() {
selectedMembersRef.current = selectedMembers
}, [relationMode, selectedMembers])
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
// 从 URL 参数读取视图模式
useEffect(() => {
const view = searchParams.get('view')
@@ -343,6 +353,11 @@ export default function TreePage() {
}
}
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
// Handle root not found
if (!treeData.rootId) {
return (
+14 -22
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { SiteHeader } from "@/components/site-header"
@@ -10,16 +10,24 @@ import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { ArrowLeft, TreePine } from "lucide-react"
import { ArrowLeft, TreePine, User } from "lucide-react"
import Link from "next/link"
import { EmptyStateBadge } from "@/components/empty-state-badge"
export default function NewTreePage() {
const router = useRouter()
const { data: session } = useSession()
const { data: session, status } = useSession()
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -59,25 +67,9 @@ export default function NewTreePage() {
}
}
if (!session) {
return (
<div className="min-h-screen bg-background flex flex-col">
<SiteHeader />
<div className="flex-1 flex items-center justify-center">
<Card className="w-full max-w-md mx-4">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardFooter>
<Button asChild className="w-full">
<Link href="/auth/signin"></Link>
</Button>
</CardFooter>
</Card>
</div>
</div>
)
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
return (
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises'
import { join } from 'path'
import { existsSync } from 'fs'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params
// 构建文件路径
const filePath = join(process.cwd(), 'public', 'uploads', filename)
// 检查文件是否存在
if (!existsSync(filePath)) {
return new NextResponse('File not found', { status: 404 })
}
// 读取文件
const fileBuffer = await readFile(filePath)
// 根据文件扩展名设置 Content-Type
const ext = filename.split('.').pop()?.toLowerCase()
const contentTypeMap: Record<string, string> = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
}
const contentType = contentTypeMap[ext || ''] || 'application/octet-stream'
// 返回文件
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
} catch (error) {
console.error('读取文件失败:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}