0.0.0.4
This commit is contained in:
+12
-1
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google"
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import "./globals.css"
|
||||
import { FamilyProvider } from "@/context/family-context"
|
||||
import { PWAProvider } from "@/components/pwa/pwa-provider"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
||||
@@ -12,6 +13,12 @@ export const metadata: Metadata = {
|
||||
title: "华夏谱 (HuaXiaPu) - 家族树管理系统",
|
||||
description: "现代中国家族树管理系统",
|
||||
generator: "v0.app",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "华夏谱",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
@@ -39,7 +46,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`font-sans antialiased`}>
|
||||
<FamilyProvider>{children}</FamilyProvider>
|
||||
<FamilyProvider>
|
||||
<PWAProvider>
|
||||
{children}
|
||||
</PWAProvider>
|
||||
</FamilyProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+148
-22
@@ -7,10 +7,12 @@ import { useFamily } from "@/context/family-context"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { MemberForm } from "@/components/members/member-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft, Edit, Trash, Share2, Printer } from "lucide-react"
|
||||
import { ArrowLeft, Edit, Trash, Share2, Printer, CircleUser, CircleUserRound, Images, BookOpen } from "lucide-react"
|
||||
import { useState, useEffect } from "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"
|
||||
|
||||
export default function MemberProfilePage() {
|
||||
const { id } = useParams()
|
||||
@@ -85,14 +87,18 @@ export default function MemberProfilePage() {
|
||||
<div className="flex flex-col md:flex-row justify-between items-start gap-6 mb-8 border-b border-border pb-8">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="h-32 w-32 rounded-lg bg-muted flex items-center justify-center border-2 border-primary/20 overflow-hidden">
|
||||
{avatarBlobUrl || member.avatarUrl ? (
|
||||
{avatarBlobUrl ? (
|
||||
<img
|
||||
src={avatarBlobUrl || member.avatarUrl || "/placeholder.svg"}
|
||||
src={avatarBlobUrl}
|
||||
alt={member.fullName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-4xl font-serif font-bold text-muted-foreground/50">{member.surname}</span>
|
||||
member.gender === 'female' ? (
|
||||
<CircleUserRound className="h-20 w-20 text-pink-400" />
|
||||
) : (
|
||||
<CircleUser className="h-20 w-20 text-blue-400" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -152,6 +158,40 @@ export default function MemberProfilePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
{member.photoIds && member.photoIds.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
|
||||
<Images className="h-6 w-6 text-primary" />
|
||||
照片墙
|
||||
</h2>
|
||||
<div className="bg-card p-6 rounded-lg border border-border shadow-sm">
|
||||
<PhotoGallery
|
||||
photoIds={member.photoIds}
|
||||
onChange={(photoIds) => updateMember(member.id, { photoIds })}
|
||||
readonly={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Family Stories */}
|
||||
{member.stories && member.stories.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
|
||||
<BookOpen className="h-6 w-6 text-primary" />
|
||||
家族故事
|
||||
</h2>
|
||||
<div className="bg-card p-6 rounded-lg border border-border shadow-sm">
|
||||
<StoryManager
|
||||
stories={member.stories}
|
||||
onChange={(stories) => updateMember(member.id, { stories })}
|
||||
readonly={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
|
||||
<span className="w-1 h-6 bg-secondary rounded-full"></span>
|
||||
@@ -167,12 +207,47 @@ export default function MemberProfilePage() {
|
||||
href={`/members/${member.fatherId}`}
|
||||
className="text-lg font-serif font-bold hover:underline text-primary"
|
||||
>
|
||||
查看父亲资料
|
||||
{getMember(member.fatherId)?.fullName || member.fatherId}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card p-4 rounded-lg border border-border">
|
||||
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
|
||||
母亲 (Mother)
|
||||
</h3>
|
||||
{member.motherId ? (
|
||||
<Link
|
||||
href={`/members/${member.motherId}`}
|
||||
className="text-lg font-serif font-bold hover:underline text-primary"
|
||||
>
|
||||
{getMember(member.motherId)?.fullName || member.motherId}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card p-4 rounded-lg border border-border">
|
||||
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
|
||||
配偶 (Spouse)
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{member.spouseIds?.length > 0 ? (
|
||||
member.spouseIds.map((spouseId) => (
|
||||
<Link
|
||||
href={`/members/${spouseId}`}
|
||||
key={spouseId}
|
||||
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
|
||||
>
|
||||
{getMember(spouseId)?.fullName || spouseId}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">无记录</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card p-4 rounded-lg border border-border">
|
||||
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
|
||||
子女 (Children)
|
||||
@@ -185,7 +260,7 @@ export default function MemberProfilePage() {
|
||||
key={childId}
|
||||
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
|
||||
>
|
||||
{childId} {/* In real app, fetch name */}
|
||||
{getMember(childId)?.fullName || childId}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
@@ -206,24 +281,75 @@ export default function MemberProfilePage() {
|
||||
<dt className="text-muted-foreground">出生日期</dt>
|
||||
<dd className="font-medium">{member.birthDate || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">逝世日期</dt>
|
||||
<dd className="font-medium">{member.deathDate || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">字辈</dt>
|
||||
<dd className="font-medium">{member.generationName || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">谥号</dt>
|
||||
<dd className="font-medium">{member.posthumousName || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">墓地</dt>
|
||||
<dd className="font-medium">{member.burialPlace || "-"}</dd>
|
||||
</div>
|
||||
{member.deathDate && (
|
||||
<>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">逝世日期</dt>
|
||||
<dd className="font-medium">{member.deathDate}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">谥号</dt>
|
||||
<dd className="font-medium">{member.posthumousName || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">墓地</dt>
|
||||
<dd className="font-medium">{member.burialPlace || "-"}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{member.generationName && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">字辈</dt>
|
||||
<dd className="font-medium">{member.generationName}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 联系方式 */}
|
||||
{(member.phone || member.telephone || member.email || member.address) && (
|
||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
|
||||
<h3 className="font-serif font-bold text-lg mb-4 border-b border-border pb-2">联系方式</h3>
|
||||
<dl className="space-y-4 text-sm">
|
||||
{member.phone && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">手机号</dt>
|
||||
<dd className="font-medium">
|
||||
<a href={`tel:${member.phone}`} className="hover:text-primary">
|
||||
{member.phone}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{member.telephone && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">固定电话</dt>
|
||||
<dd className="font-medium">
|
||||
<a href={`tel:${member.telephone}`} className="hover:text-primary">
|
||||
{member.telephone}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{member.email && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">邮箱</dt>
|
||||
<dd className="font-medium">
|
||||
<a href={`mailto:${member.email}`} className="hover:text-primary">
|
||||
{member.email}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{member.address && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">现居地址</dt>
|
||||
<dd className="font-medium">{member.address}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+115
-58
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -16,9 +17,19 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
|
||||
export default function MembersPage() {
|
||||
const { treeData, searchMembers } = useFamily()
|
||||
const { treeData, searchMembers, setHighlightedMemberId } = useFamily()
|
||||
const router = useRouter()
|
||||
const [query, setQuery] = useState("")
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// 从 URL 参数读取搜索关键词
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const searchParam = params.get('search')
|
||||
if (searchParam) {
|
||||
setQuery(searchParam)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Filter States
|
||||
const [selectedSurname, setSelectedSurname] = useState<string>("all")
|
||||
@@ -100,6 +111,27 @@ export default function MembersPage() {
|
||||
// Sort
|
||||
return result.sort((a, b) => a.generation - b.generation || (a.birthDate || "").localeCompare(b.birthDate || ""))
|
||||
}, [allMembers, query, selectedSurname, selectedHome, statusFilter, generationRange])
|
||||
|
||||
// 按世代分组
|
||||
const membersByGeneration = useMemo(() => {
|
||||
const groups = new Map<number, typeof filteredMembers>()
|
||||
|
||||
filteredMembers.forEach(member => {
|
||||
const gen = member.generation
|
||||
if (!groups.has(gen)) {
|
||||
groups.set(gen, [])
|
||||
}
|
||||
groups.get(gen)!.push(member)
|
||||
})
|
||||
|
||||
// 转换为数组并排序
|
||||
return Array.from(groups.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([generation, members]) => ({
|
||||
generation,
|
||||
members
|
||||
}))
|
||||
}, [filteredMembers])
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedSurname("all")
|
||||
@@ -233,62 +265,87 @@ export default function MembersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
没有找到匹配的成员。
|
||||
</div>
|
||||
) : (
|
||||
filteredMembers.map((member) => (
|
||||
<Link href={`/members/${member.id}`} key={member.id}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer h-full">
|
||||
<CardContent className="p-6 flex items-start gap-4">
|
||||
<AvatarDisplay
|
||||
imageId={member.avatarImageId}
|
||||
fallbackUrl={member.avatarUrl}
|
||||
fallbackText={member.surname}
|
||||
className="h-16 w-16 border-2 border-border"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-xl font-bold font-serif truncate">
|
||||
{member.fullName}
|
||||
{member.courtesyName && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
字 {member.courtesyName}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground whitespace-nowrap">
|
||||
{member.generation}世
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{member.generationName ? `字辈: ${member.generationName}` : "未录入字辈"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{member.ancestralHome || "籍贯未知"}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{member.deathDate && (
|
||||
<span className="text-[10px] bg-neutral-100 text-neutral-600 px-1.5 py-0.5 rounded border border-neutral-200">
|
||||
已故
|
||||
</span>
|
||||
)}
|
||||
{member.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
没有找到匹配的成员。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{membersByGeneration.map(({ generation, members }) => (
|
||||
<div key={generation}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h2 className="text-xl font-serif font-bold text-foreground">第 {generation} 世</h2>
|
||||
<span className="text-sm text-muted-foreground">共 {members.length} 人</span>
|
||||
<div className="flex-1 h-px bg-border"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="relative group">
|
||||
<Link href={`/members/${member.id}`}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer h-full">
|
||||
<CardContent className="p-3 flex items-start gap-2.5">
|
||||
<AvatarDisplay
|
||||
imageId={member.avatarImageId}
|
||||
fallbackUrl={member.avatarUrl}
|
||||
fallbackText={member.surname}
|
||||
gender={member.gender}
|
||||
className="h-10 w-10 border border-border"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-1.5">
|
||||
<h3 className="text-base font-bold font-serif truncate">
|
||||
{member.fullName}
|
||||
{member.courtesyName && (
|
||||
<span className="text-[11px] font-normal text-muted-foreground ml-1">
|
||||
字 {member.courtesyName}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||
{member.generationName ? `字辈: ${member.generationName}` : "未录入字辈"}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">{member.ancestralHome || "籍贯未知"}</p>
|
||||
{(member.deathDate || member.tags?.length) && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{member.deathDate && (
|
||||
<span className="text-[9px] bg-neutral-100 text-neutral-600 px-1 py-0.5 rounded border border-neutral-200">
|
||||
已故
|
||||
</span>
|
||||
)}
|
||||
{member.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[9px] bg-primary/10 text-primary px-1 py-0.5 rounded border border-primary/20"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||||
onClick={() => {
|
||||
setHighlightedMemberId(member.id)
|
||||
router.push('/tree')
|
||||
}}
|
||||
title="在族谱图中定位"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
+318
-81
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -5,9 +7,89 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen } from "lucide-react"
|
||||
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen, BarChart3 } from "lucide-react"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useMemo } from "react"
|
||||
import { StatisticsCharts } from "@/components/dashboard/statistics-charts"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { treeData } = useFamily()
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
const members = Object.values(treeData.members)
|
||||
const totalMembers = members.length
|
||||
|
||||
// 计算最大代数
|
||||
const maxGeneration = Math.max(...members.map(m => m.generation || 0))
|
||||
|
||||
// 计算最早出生年份
|
||||
const birthYears = members
|
||||
.map(m => m.birthDate ? new Date(m.birthDate).getFullYear() : null)
|
||||
.filter(y => y !== null) as number[]
|
||||
const earliestYear = birthYears.length > 0 ? Math.min(...birthYears) : new Date().getFullYear()
|
||||
const yearsSpan = new Date().getFullYear() - earliestYear
|
||||
|
||||
return {
|
||||
totalMembers,
|
||||
maxGeneration,
|
||||
yearsSpan,
|
||||
earliestYear
|
||||
}
|
||||
}, [treeData])
|
||||
|
||||
// 获取最近的成员(按ID排序,取最新的5个)
|
||||
const recentMembers = useMemo(() => {
|
||||
return Object.values(treeData.members)
|
||||
.sort((a, b) => parseInt(b.id) - parseInt(a.id))
|
||||
.slice(0, 5)
|
||||
}, [treeData])
|
||||
|
||||
// 获取本月纪念日(生日和忌日)
|
||||
const monthlyAnniversaries = useMemo(() => {
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
const members = Object.values(treeData.members)
|
||||
|
||||
const anniversaries: Array<{
|
||||
member: any
|
||||
type: 'birth' | 'death'
|
||||
date: string
|
||||
day: number
|
||||
}> = []
|
||||
|
||||
members.forEach(member => {
|
||||
// 检查生日
|
||||
if (member.birthDate) {
|
||||
const birthMonth = new Date(member.birthDate).getMonth() + 1
|
||||
const birthDay = new Date(member.birthDate).getDate()
|
||||
if (birthMonth === currentMonth) {
|
||||
anniversaries.push({
|
||||
member,
|
||||
type: 'birth',
|
||||
date: member.birthDate,
|
||||
day: birthDay
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查忌日
|
||||
if (member.deathDate) {
|
||||
const deathMonth = new Date(member.deathDate).getMonth() + 1
|
||||
const deathDay = new Date(member.deathDate).getDate()
|
||||
if (deathMonth === currentMonth) {
|
||||
anniversaries.push({
|
||||
member,
|
||||
type: 'death',
|
||||
date: member.deathDate,
|
||||
day: deathDay
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 按日期排序
|
||||
return anniversaries.sort((a, b) => a.day - b.day).slice(0, 5)
|
||||
}, [treeData])
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background font-sans">
|
||||
<SiteHeader />
|
||||
@@ -35,9 +117,24 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<StatCard title="家族成员" value="1,248" subtitle="较上月新增 12 人" icon={<Users className="h-4 w-4" />} />
|
||||
<StatCard title="记录年代" value="642 年" subtitle="始于 明朝洪武年间" icon={<Clock className="h-4 w-4" />} />
|
||||
<StatCard title="繁衍代数" value="24 代" subtitle="平均代差 26.5 年" icon={<Network className="h-4 w-4" />} />
|
||||
<StatCard
|
||||
title="家族成员"
|
||||
value={stats.totalMembers.toString()}
|
||||
subtitle={`记录在册的家族成员`}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="记录年代"
|
||||
value={`${stats.yearsSpan} 年`}
|
||||
subtitle={`始于 ${stats.earliestYear} 年`}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="繁衍代数"
|
||||
value={`${stats.maxGeneration} 代`}
|
||||
subtitle={`当前最高世代`}
|
||||
icon={<Network className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="recent" className="w-full">
|
||||
@@ -45,11 +142,14 @@ export default function DashboardPage() {
|
||||
<TabsList className="bg-muted/50">
|
||||
<TabsTrigger value="recent">最近更新</TabsTrigger>
|
||||
<TabsTrigger value="anniversaries">近期纪念日</TabsTrigger>
|
||||
<TabsTrigger value="statistics">统计图表</TabsTrigger>
|
||||
<TabsTrigger value="migration">迁徙记录</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1">
|
||||
查看全部 <ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Link href="/members">
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1">
|
||||
查看全部 <ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
@@ -62,27 +162,38 @@ export default function DashboardPage() {
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-6">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex gap-4 group">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="h-full w-px bg-border group-last:hidden"></div>
|
||||
<div className="absolute top-2 h-2 w-2 rounded-full bg-primary ring-4 ring-background"></div>
|
||||
</div>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">李明远</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary/20 text-secondary-foreground">
|
||||
第 22 世
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">2小时前</span>
|
||||
{recentMembers.length > 0 ? (
|
||||
recentMembers.map((member, i) => (
|
||||
<Link href={`/members/${member.id}`} key={member.id}>
|
||||
<div className="flex gap-4 group hover:bg-muted/30 p-2 rounded-lg transition-colors cursor-pointer">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="h-full w-px bg-border group-last:hidden"></div>
|
||||
<div className="absolute top-2 h-2 w-2 rounded-full bg-primary ring-4 ring-background"></div>
|
||||
</div>
|
||||
<div className="pb-2 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{member.fullName}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary/20 text-secondary-foreground">
|
||||
第 {member.generation} 世
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{member.gender === 'male' ? '男' : '女'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{member.birthDate && `生于 ${member.birthDate.split('-')[0]} 年`}
|
||||
{member.deathDate && ` · 卒于 ${member.deathDate.split('-')[0]} 年`}
|
||||
{!member.deathDate && member.birthDate && ` · 在世`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
更新了其长子 <span className="text-foreground font-medium">李子豪</span>{" "}
|
||||
的教育经历与职业信息。
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
暂无成员记录
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
@@ -97,67 +208,193 @@ export default function DashboardPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50">
|
||||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
|
||||
<span className="text-xs">农历</span>
|
||||
<span className="font-bold text-lg">十五</span>
|
||||
{monthlyAnniversaries.length > 0 ? (
|
||||
monthlyAnniversaries.map((anniversary, index) => {
|
||||
const yearsAgo = new Date().getFullYear() - new Date(anniversary.date).getFullYear()
|
||||
return (
|
||||
<Link href={`/members/${anniversary.member.id}`} key={`${anniversary.member.id}-${anniversary.type}`}>
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
|
||||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
|
||||
<span className="text-xs">
|
||||
{new Date(anniversary.date).getMonth() + 1}月
|
||||
</span>
|
||||
<span className="font-bold text-lg">{anniversary.day}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{anniversary.member.fullName} {anniversary.type === 'birth' ? '诞辰' : '忌日'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
第 {anniversary.member.generation} 世 · 距今 {yearsAgo} 年
|
||||
{anniversary.type === 'death' && ' · 已故'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8 text-sm">
|
||||
本月暂无纪念日
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">李公讳文德 诞辰</p>
|
||||
<p className="text-sm text-muted-foreground">第 18 世祖 · 距今 120 年</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50">
|
||||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
|
||||
<span className="text-xs">农历</span>
|
||||
<span className="font-bold text-lg">廿二</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">冬至祭祖</p>
|
||||
<p className="text-sm text-muted-foreground">全族公祭 · 陇西堂旧址</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif">快捷入口</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
|
||||
>
|
||||
<Map className="h-5 w-5" />
|
||||
<span className="text-xs">迁徙地图</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
<span className="text-xs">家训家规</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
<span className="text-xs">字辈查询</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
<span className="text-xs">关系计算</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 近期纪念日标签页 */}
|
||||
<TabsContent value="anniversaries" className="mt-0">
|
||||
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif">近期纪念日</CardTitle>
|
||||
<CardDescription>未来三个月内的生日和忌日</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(() => {
|
||||
const now = new Date()
|
||||
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
|
||||
const members = Object.values(treeData.members)
|
||||
const upcomingEvents: Array<{
|
||||
member: any
|
||||
type: 'birth' | 'death'
|
||||
date: Date
|
||||
originalDate: string
|
||||
}> = []
|
||||
|
||||
members.forEach(member => {
|
||||
// 生日
|
||||
if (member.birthDate) {
|
||||
const birthDate = new Date(member.birthDate)
|
||||
const thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
|
||||
if (thisYearBirth >= now && thisYearBirth <= threeMonthsLater) {
|
||||
upcomingEvents.push({
|
||||
member,
|
||||
type: 'birth',
|
||||
date: thisYearBirth,
|
||||
originalDate: member.birthDate
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 忌日
|
||||
if (member.deathDate) {
|
||||
const deathDate = new Date(member.deathDate)
|
||||
const thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
|
||||
if (thisYearDeath >= now && thisYearDeath <= threeMonthsLater) {
|
||||
upcomingEvents.push({
|
||||
member,
|
||||
type: 'death',
|
||||
date: thisYearDeath,
|
||||
originalDate: member.deathDate
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
upcomingEvents.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
|
||||
return upcomingEvents.length > 0 ? (
|
||||
upcomingEvents.map((event, index) => {
|
||||
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
|
||||
return (
|
||||
<Link href={`/members/${event.member.id}`} key={`${event.member.id}-${event.type}-${index}`}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-primary/10 text-primary font-serif">
|
||||
<span className="text-xs">{event.date.getMonth() + 1}月</span>
|
||||
<span className="font-bold text-lg">{event.date.getDate()}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">
|
||||
{event.member.fullName} {event.type === 'birth' ? '诞辰' : '忌日'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
第 {event.member.generation} 世 · {yearsAgo} 年
|
||||
{event.type === 'death' && ' · 已故'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
未来三个月暂无纪念日
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 统计图表标签页 */}
|
||||
<TabsContent value="statistics" className="mt-0">
|
||||
<StatisticsCharts members={Object.values(treeData.members)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* 迁徙记录标签页 */}
|
||||
<TabsContent value="migration" className="mt-0">
|
||||
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif">家族迁徙记录</CardTitle>
|
||||
<CardDescription>记录家族成员的籍贯分布</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{useMemo(() => {
|
||||
const members = Object.values(treeData.members)
|
||||
const locationGroups: Record<string, any[]> = {}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.ancestralHome) {
|
||||
if (!locationGroups[member.ancestralHome]) {
|
||||
locationGroups[member.ancestralHome] = []
|
||||
}
|
||||
locationGroups[member.ancestralHome].push(member)
|
||||
}
|
||||
})
|
||||
|
||||
const sortedLocations = Object.entries(locationGroups)
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
|
||||
return sortedLocations.length > 0 ? (
|
||||
sortedLocations.map(([location, locationMembers]) => (
|
||||
<div key={location} className="border-l-4 border-primary pl-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-serif font-bold">{location}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{locationMembers.length} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{locationMembers.map((member: any) => (
|
||||
<Link href={`/members/${member.id}`} key={member.id}>
|
||||
<div className="px-3 py-2 bg-muted rounded hover:bg-muted/80 transition-colors">
|
||||
<p className="text-sm font-medium truncate">{member.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">第 {member.generation} 世</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
暂无迁徙记录
|
||||
</div>
|
||||
)
|
||||
}, [treeData])}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+69
-1
@@ -4,14 +4,18 @@ 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 } from "lucide-react"
|
||||
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { useRef, useState } from "react"
|
||||
import { exportToGedcom, importFromGedcom } from "@/lib/gedcom"
|
||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { treeData, loadData, resetData } = useFamily()
|
||||
const [importStatus, setImportStatus] = useState<string>("")
|
||||
const [gedcomStatus, setGedcomStatus] = useState<string>("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const gedcomInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col font-sans">
|
||||
@@ -20,6 +24,9 @@ export default function SettingsPage() {
|
||||
<h1 className="text-3xl font-serif font-bold mb-8">系统设置 (Settings)</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 通知设置 */}
|
||||
<NotificationSettings />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -98,6 +105,67 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" /> GEDCOM 格式
|
||||
</CardTitle>
|
||||
<CardDescription>导入/导出标准 GEDCOM 格式,兼容其他家谱软件(如 Ancestry、MyHeritage)。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const gedcomText = exportToGedcom(treeData)
|
||||
const blob = new Blob([gedcomText], { type: "text/plain;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = `family_tree_${new Date().toISOString().split("T")[0]}.ged`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 GEDCOM
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={gedcomInputRef}
|
||||
className="hidden"
|
||||
accept=".ged,.gedcom"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const gedcomText = event.target?.result as string
|
||||
const importedData = importFromGedcom(gedcomText)
|
||||
await loadData(importedData)
|
||||
setGedcomStatus("GEDCOM 导入成功!")
|
||||
if (gedcomInputRef.current) gedcomInputRef.current.value = ""
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setGedcomStatus("错误:无法解析 GEDCOM 文件")
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => gedcomInputRef.current?.click()}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
导入 GEDCOM
|
||||
</Button>
|
||||
{gedcomStatus && <span className="text-sm text-muted-foreground">{gedcomStatus}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
+285
-25
@@ -4,11 +4,116 @@ 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, ArrowRight } from "lucide-react"
|
||||
import { Clock, Map as MapIcon, ArrowRight } from "lucide-react"
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useMemo, useEffect, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false })
|
||||
|
||||
// 中国地图 GeoJSON 数据 - 完整的轮廓
|
||||
const chinaGeoJSON = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: { name: "中国" },
|
||||
geometry: {
|
||||
type: "MultiPolygon",
|
||||
coordinates: [
|
||||
[
|
||||
[
|
||||
// 中国大陆完整轮廓 - 从东北开始逆时针
|
||||
[134.5, 48.5], [135, 47], [134, 45], [133, 43], [131, 42], [130, 43], [129, 44],
|
||||
[128, 45], [127, 46], [126, 47], [125, 48], [124, 49], [123.5, 50], [123, 51],
|
||||
[122.5, 52], [122, 53], [121, 53.5], [120, 53.3], [119, 53], [118.5, 52],
|
||||
[119, 51], [119.5, 50], [120, 49], [121, 48], [122, 47], [123, 46], [124, 45],
|
||||
[125, 44], [126, 43], [127, 42.5], [128, 42], [129, 41.5], [130, 41],
|
||||
// 东部海岸线
|
||||
[130, 40], [129, 39], [128, 38.5], [127, 38], [126, 37.5], [125, 37],
|
||||
[124, 36.5], [123, 36], [122.5, 35.5], [122, 35], [121.5, 34.5], [121, 34],
|
||||
[120.5, 33.5], [120, 33], [119.5, 32.5], [119.5, 32], [120, 31.5], [120.5, 31],
|
||||
[121, 30.5], [121.5, 30], [122, 29.5], [122, 29], [121.5, 28.5], [121, 28],
|
||||
[120.5, 27.5], [120, 27], [119.5, 26.5], [119, 26], [118.5, 25.5], [118, 25],
|
||||
[117.5, 24.5], [117, 24], [116.5, 23.5], [116, 23], [115.5, 22.5], [115, 22],
|
||||
[114, 21.8], [113, 21.5], [112, 21.3], [111, 21.2], [110.5, 21],
|
||||
// 南部边界
|
||||
[110, 20.5], [109.5, 20], [109, 20.5], [108.5, 21], [108, 21.5], [107.5, 22],
|
||||
[107, 22.5], [106.5, 23], [106, 23.5], [105.5, 24], [105, 24.5], [104.5, 25],
|
||||
[104, 25.5], [103.5, 26], [103, 26.5], [102.5, 27], [102, 27.5], [101.5, 28],
|
||||
[101, 28.5], [100.5, 29], [100, 29.5], [99.5, 30], [99, 30.5], [98.5, 31],
|
||||
[98, 31.5], [97.5, 32], [97, 32.5],
|
||||
// 西南边界
|
||||
[96.5, 33], [96, 33.5], [95.5, 34], [95, 34.5], [94.5, 35], [94, 35.5],
|
||||
[93.5, 36], [93, 36.5], [92.5, 37], [92, 37.5], [91.5, 38], [91, 38.5],
|
||||
[90.5, 39], [90, 39.5], [89.5, 40], [89, 40.5], [88.5, 41], [88, 41.5],
|
||||
[87.5, 42], [87, 42.5], [86.5, 43], [86, 43.5], [85.5, 44], [85, 44.5],
|
||||
[84.5, 45], [84, 45.5], [83.5, 46], [83, 46.5], [82.5, 47], [82, 47.5],
|
||||
[81.5, 48], [81, 48.5], [80.5, 49], [80, 49.5],
|
||||
// 西北边界
|
||||
[79.5, 49.5], [79, 49.3], [78.5, 49], [78, 48.5], [77.5, 48], [77, 47.5],
|
||||
[76.5, 47], [76, 46.5], [75.5, 46], [75, 45.5], [74.5, 45], [74, 44.5],
|
||||
[73.5, 44], [73.5, 43.5], [74, 43], [74.5, 42.5], [75, 42], [75.5, 41.5],
|
||||
[76, 41], [76.5, 40.5], [77, 40], [77.5, 39.5], [78, 39], [78.5, 38.5],
|
||||
[79, 38.5], [79.5, 39], [80, 39.5], [80.5, 40], [81, 40.5], [81.5, 41],
|
||||
[82, 41.5], [82.5, 42], [83, 42.5], [83.5, 43], [84, 43.5], [84.5, 44],
|
||||
[85, 44.5], [85.5, 45], [86, 45.5], [86.5, 46], [87, 46.5], [87.5, 47],
|
||||
[88, 47.5], [88.5, 48], [89, 48.5], [89.5, 49], [90, 49.5], [90.5, 50],
|
||||
[91, 50.5], [91.5, 51], [92, 51.5], [92.5, 52], [93, 52.5], [93.5, 53],
|
||||
// 北部边界
|
||||
[94, 53], [95, 52.8], [96, 52.5], [97, 52.2], [98, 52], [99, 51.8],
|
||||
[100, 51.5], [101, 51.2], [102, 51], [103, 50.8], [104, 50.5], [105, 50.2],
|
||||
[106, 50], [107, 49.8], [108, 49.5], [109, 49.3], [110, 49.2], [111, 49.1],
|
||||
[112, 49], [113, 48.9], [114, 48.8], [115, 48.7], [116, 48.6], [117, 48.5],
|
||||
[118, 48.5], [119, 48.5], [120, 48.6], [121, 48.7], [122, 48.8], [123, 48.9],
|
||||
[124, 49], [125, 49.1], [126, 49.2], [127, 49.3], [128, 49.4], [129, 49.5],
|
||||
[130, 49.5], [131, 49.4], [132, 49.2], [133, 49], [134, 48.8], [134.5, 48.5]
|
||||
]
|
||||
],
|
||||
// 海南岛
|
||||
[[[108.6, 18.5], [109.5, 18.3], [110.5, 18.5], [111, 19], [111, 19.5], [110.5, 20], [109.5, 20.2], [108.6, 20], [108.2, 19.5], [108.6, 18.5]]],
|
||||
// 台湾岛
|
||||
[[[120, 22], [120.5, 21.9], [121, 22], [121.5, 22.5], [122, 23], [122, 23.5], [122, 24], [121.8, 24.5], [121.5, 25], [121, 25.2], [120.5, 25.1], [120.2, 24.8], [120, 24.5], [120, 24], [120, 23.5], [120, 23], [120, 22.5], [120, 22]]]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 城市坐标数据
|
||||
const geoCoordMap: Record<string, [number, number]> = {
|
||||
'广州': [113.23, 23.16],
|
||||
'深圳': [114.07, 22.62],
|
||||
'北京': [116.46, 39.92],
|
||||
'上海': [121.48, 31.22],
|
||||
'成都': [104.06, 30.67],
|
||||
'杭州': [120.19, 30.26],
|
||||
'南京': [118.78, 32.04],
|
||||
'武汉': [114.31, 30.52],
|
||||
'西安': [108.95, 34.27],
|
||||
'厦门': [118.10, 24.46]
|
||||
}
|
||||
|
||||
export default function TimelinePage() {
|
||||
const { treeData, getMember } = useFamily()
|
||||
const members = Object.values(treeData.members)
|
||||
const [mapLoaded, setMapLoaded] = useState(false)
|
||||
|
||||
// 注册中国地图 - 从外部文件加载完整数据
|
||||
useEffect(() => {
|
||||
fetch('/china.json')
|
||||
.then(response => response.json())
|
||||
.then(geoJson => {
|
||||
echarts.registerMap('china', geoJson)
|
||||
setMapLoaded(true)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load China map:', error)
|
||||
// 如果加载失败,使用简化版本
|
||||
echarts.registerMap('china', chinaGeoJSON as any)
|
||||
setMapLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// -- Timeline Logic --
|
||||
// Flatten events: Births and Deaths
|
||||
@@ -61,6 +166,131 @@ export default function TimelinePage() {
|
||||
return []
|
||||
})
|
||||
.sort((a, b) => (a.year || 0) - (b.year || 0))
|
||||
|
||||
// ECharts 地图配置
|
||||
const mapOption = useMemo(() => {
|
||||
// 城市坐标映射(简化城市名)
|
||||
const cityMap: Record<string, string> = {
|
||||
'广东省广州市': '广州',
|
||||
'广东省深圳市': '深圳',
|
||||
'北京市': '北京',
|
||||
'上海市': '上海',
|
||||
'四川省成都市': '成都',
|
||||
}
|
||||
|
||||
// 转换迁徙数据为 ECharts 格式(使用经纬度坐标)
|
||||
const lines = migrations.map(mig => {
|
||||
const fromCity = cityMap[mig.from] || mig.from
|
||||
const toCity = cityMap[mig.to] || mig.to
|
||||
const fromCoord = geoCoordMap[fromCity]
|
||||
const toCoord = geoCoordMap[toCity]
|
||||
|
||||
if (!fromCoord || !toCoord) return null
|
||||
|
||||
return {
|
||||
fromName: fromCity,
|
||||
toName: toCity,
|
||||
coords: [fromCoord, toCoord]
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
// 统计每个城市的迁入迁出
|
||||
const cityData: Record<string, number> = {}
|
||||
migrations.forEach(mig => {
|
||||
const from = cityMap[mig.from] || mig.from
|
||||
const to = cityMap[mig.to] || mig.to
|
||||
cityData[from] = (cityData[from] || 0) + 1
|
||||
cityData[to] = (cityData[to] || 0) + 1
|
||||
})
|
||||
|
||||
const scatterData = Object.entries(cityData).map(([name, value]) => {
|
||||
const coord = geoCoordMap[name]
|
||||
if (!coord) return null
|
||||
return {
|
||||
name,
|
||||
value: [...coord, value * 10]
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
geo: {
|
||||
map: 'china',
|
||||
roam: true,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#666',
|
||||
fontSize: 12
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(128, 128, 128, 0.15)',
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)',
|
||||
borderWidth: 2
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(128, 128, 128, 0.25)'
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
color: '#333',
|
||||
fontSize: 14
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'lines',
|
||||
coordinateSystem: 'geo',
|
||||
data: lines,
|
||||
lineStyle: {
|
||||
color: 'transparent',
|
||||
width: 0,
|
||||
opacity: 0
|
||||
},
|
||||
effect: {
|
||||
show: true,
|
||||
period: 3,
|
||||
trailLength: 0,
|
||||
symbol: 'arrow',
|
||||
symbolSize: 10,
|
||||
color: 'hsl(var(--primary))'
|
||||
},
|
||||
zlevel: 1
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'geo',
|
||||
data: scatterData,
|
||||
symbolSize: 10,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}',
|
||||
position: 'right',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#000',
|
||||
shadowBlur: 0,
|
||||
borderWidth: 0
|
||||
},
|
||||
emphasis: {
|
||||
scale: true,
|
||||
itemStyle: {
|
||||
color: '#000'
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}, [migrations])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col font-sans">
|
||||
@@ -77,7 +307,7 @@ export default function TimelinePage() {
|
||||
<Clock className="h-4 w-4" /> 时间轴 (Chronology)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="migration" className="flex items-center gap-2">
|
||||
<Map className="h-4 w-4" /> 迁徙图 (Migration)
|
||||
<MapIcon className="h-4 w-4" /> 迁徙图 (Migration)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -99,27 +329,33 @@ export default function TimelinePage() {
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className="bg-card border border-border rounded-lg p-4 shadow-sm transition-all hover:shadow-md hover:border-primary/50 max-w-xl">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="bg-card rounded-lg px-4 py-2 transition-all hover:bg-accent/5">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span
|
||||
className={`text-xs font-bold px-2 py-0.5 rounded-full ${event.type === "birth" ? "bg-primary/10 text-primary" : "bg-neutral-100 text-neutral-500"
|
||||
className={`text-xs px-2 py-0.5 rounded-full flex-shrink-0 ${event.type === "birth" ? "bg-primary/10 text-primary" : "bg-neutral-100 text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{event.type === "birth" ? "诞生" : "逝世"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{event.date}</span>
|
||||
<span className="font-serif font-bold text-base">{event.member.fullName}</span>
|
||||
<span className="text-sm text-muted-foreground">第{event.member.generation}世</span>
|
||||
{event.type === "birth" && (event.member.birthPlace || event.member.ancestralHome) && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
出生于 {event.member.birthPlace || event.member.ancestralHome}
|
||||
</span>
|
||||
)}
|
||||
{event.type === "death" && event.member.birthDate && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
享年 {event.year - Number.parseInt(event.member.birthDate.substring(0, 4))} 岁
|
||||
</span>
|
||||
)}
|
||||
{event.type === "death" && event.member.burialPlace && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
葬于 {event.member.burialPlace}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono ml-auto">{event.date}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-serif font-bold">
|
||||
{event.member.fullName}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({event.member.generation}世)
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{event.type === "birth"
|
||||
? `出生于 ${event.member.birthPlace || event.member.ancestralHome || "未知地点"}`
|
||||
: `享年 ${event.year - Number.parseInt(event.member.birthDate?.substring(0, 4) || "0")} 岁`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -176,14 +412,38 @@ export default function TimelinePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-50 dark:bg-neutral-900 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center h-[600px] text-muted-foreground">
|
||||
<Map className="h-16 w-16 mb-4 opacity-20" />
|
||||
<p className="max-w-xs text-center">
|
||||
地图可视化需要地理编码服务支持。
|
||||
<br />
|
||||
当前版本仅支持列表展示迁徙路径。
|
||||
</p>
|
||||
<Card className="bg-gradient-to-br from-primary/5 via-background to-secondary/5 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif">迁徙路径图 (Migration Flow)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative h-[600px] overflow-hidden rounded-lg bg-background/50 backdrop-blur border border-border/50">
|
||||
{!mapLoaded ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<MapIcon className="h-16 w-16 mx-auto mb-4 opacity-20 animate-pulse" />
|
||||
<p>加载地图中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : migrations.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<MapIcon className="h-16 w-16 mx-auto mb-4 opacity-20" />
|
||||
<p>暂无迁徙数据</p>
|
||||
<p className="text-sm mt-2">请先清空数据库重新加载数据</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReactECharts
|
||||
key="migration-map"
|
||||
option={mapOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
+129
-4
@@ -6,7 +6,7 @@ import { SiteHeader } from "@/components/site-header"
|
||||
import { TreeLayout } from "@/components/tree/tree-layout"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ZoomIn, ZoomOut, Move, Filter } from "lucide-react"
|
||||
import { ZoomIn, ZoomOut, Move, Filter, Download } from "lucide-react"
|
||||
import { useState, useRef } from "react"
|
||||
|
||||
export default function TreePage() {
|
||||
@@ -15,15 +15,29 @@ export default function TreePage() {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
|
||||
const [dragStartPos, setDragStartPos] = useState({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// 如果点击的是节点卡片,不启动拖拽
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.family-node-card')) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragging(true)
|
||||
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y })
|
||||
setDragStartPos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
// 只有移动超过5px才算拖拽,避免误触
|
||||
const deltaX = Math.abs(e.clientX - dragStartPos.x)
|
||||
const deltaY = Math.abs(e.clientY - dragStartPos.y)
|
||||
if (deltaX < 5 && deltaY < 5) return
|
||||
|
||||
setPosition({
|
||||
x: e.clientX - startPos.x,
|
||||
y: e.clientY - startPos.y,
|
||||
@@ -34,6 +48,117 @@ export default function TreePage() {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleExportImage = async () => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
try {
|
||||
// 临时重置位置和缩放以获取完整视图
|
||||
const originalPosition = { ...position }
|
||||
const originalScale = scale
|
||||
setPosition({ x: 0, y: 0 })
|
||||
setScale(1)
|
||||
|
||||
// 等待 DOM 更新
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
// 找到实际的族谱容器(包含 TreeLayout 的 div)
|
||||
const treeContainer = containerRef.current.querySelector('div[style*="transform"]') as HTMLElement
|
||||
if (!treeContainer) {
|
||||
throw new Error('找不到族谱容器')
|
||||
}
|
||||
|
||||
// 查找所有连接线(使用更宽泛的选择器)
|
||||
const allDivs = treeContainer.querySelectorAll('div')
|
||||
const lineElements: HTMLElement[] = []
|
||||
|
||||
allDivs.forEach(div => {
|
||||
const el = div as HTMLElement
|
||||
const classes = String(el.className || '')
|
||||
// 查找包含 bg-foreground 的元素(连接线)
|
||||
if (classes.includes('bg-foreground')) {
|
||||
lineElements.push(el)
|
||||
}
|
||||
})
|
||||
|
||||
// 克隆容器用于导出
|
||||
const clonedContainer = treeContainer.cloneNode(true) as HTMLElement
|
||||
|
||||
// 在克隆的容器中找到对应的连接线并设置样式
|
||||
const clonedDivs = clonedContainer.querySelectorAll('div')
|
||||
let lineIndex = 0
|
||||
clonedDivs.forEach(div => {
|
||||
const el = div as HTMLElement
|
||||
const classes = String(el.className || '')
|
||||
if (classes.includes('bg-foreground') && lineElements[lineIndex]) {
|
||||
const original = lineElements[lineIndex]
|
||||
const computed = window.getComputedStyle(original)
|
||||
|
||||
// 直接设置内联样式
|
||||
el.style.backgroundColor = computed.backgroundColor
|
||||
el.style.width = computed.width
|
||||
el.style.height = computed.height
|
||||
el.style.position = 'absolute'
|
||||
el.style.left = computed.left
|
||||
el.style.top = computed.top
|
||||
|
||||
lineIndex++
|
||||
}
|
||||
})
|
||||
|
||||
// 移除卡片边框
|
||||
clonedContainer.querySelectorAll('*').forEach((child) => {
|
||||
const el = child as HTMLElement
|
||||
const classes = String(el.className || '')
|
||||
|
||||
// 跳过连接线和连接线容器
|
||||
if (classes.includes('bg-foreground') || classes.includes('pointer-events-none')) {
|
||||
return
|
||||
}
|
||||
|
||||
// 移除其他元素的边框
|
||||
el.style.border = 'none'
|
||||
el.style.outline = 'none'
|
||||
})
|
||||
|
||||
// 临时添加到 DOM
|
||||
clonedContainer.style.position = 'fixed'
|
||||
clonedContainer.style.left = '-9999px'
|
||||
clonedContainer.style.top = '0'
|
||||
document.body.appendChild(clonedContainer)
|
||||
|
||||
// 等待渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
// 动态导入 dom-to-image-more(仅在客户端)
|
||||
const domtoimage = await import('dom-to-image-more')
|
||||
|
||||
// 使用 toBlob 方法
|
||||
const blob = await domtoimage.default.toBlob(clonedContainer, {
|
||||
quality: 0.95,
|
||||
bgcolor: '#ffffff'
|
||||
})
|
||||
|
||||
// 清理克隆的容器
|
||||
document.body.removeChild(clonedContainer)
|
||||
|
||||
// 恢复原始位置和缩放
|
||||
setPosition(originalPosition)
|
||||
setScale(originalScale)
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.download = `family-tree-${new Date().toISOString().split('T')[0]}.png`
|
||||
link.href = url
|
||||
link.click()
|
||||
|
||||
// 清理
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
} catch (error) {
|
||||
console.error('导出图片失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle root not found
|
||||
if (!treeData.rootId) {
|
||||
return (
|
||||
@@ -60,15 +185,15 @@ export default function TreePage() {
|
||||
<Move className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
<Button variant="outline" size="icon" onClick={handleExportImage} title="导出为图片">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tree Canvas */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat"
|
||||
className="flex-1 overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat select-none"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
|
||||
Reference in New Issue
Block a user