0.0.9.0
This commit is contained in:
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user