0.0.0.5
This commit is contained in:
+48
-28
@@ -9,11 +9,20 @@ 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, BarChart3 } from "lucide-react"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { StatisticsCharts } from "@/components/dashboard/statistics-charts"
|
||||
import { getActivityLogs } from "@/lib/activity-logger"
|
||||
import type { ActivityLog } from "@/lib/db"
|
||||
import { format } from "date-fns"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { treeData } = useFamily()
|
||||
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
|
||||
|
||||
// 加载最近的操作日志
|
||||
useEffect(() => {
|
||||
getActivityLogs(10).then(setRecentActivities)
|
||||
}, [treeData])
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
@@ -161,37 +170,48 @@ export default function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-6">
|
||||
{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 className="space-y-4">
|
||||
{recentActivities.length > 0 ? (
|
||||
recentActivities.map((activity, i) => (
|
||||
<div key={activity.id} className="flex gap-3 group hover:bg-muted/30 p-2 rounded-lg transition-colors">
|
||||
<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 ring-4 ring-background ${
|
||||
activity.action === 'create' ? 'bg-green-500' :
|
||||
activity.action === 'update' ? 'bg-blue-500' :
|
||||
activity.action === 'delete' ? 'bg-red-500' :
|
||||
'bg-purple-500'
|
||||
}`}></div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="pb-2 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
activity.action === 'create' ? 'bg-green-100 text-green-700' :
|
||||
activity.action === 'update' ? 'bg-blue-100 text-blue-700' :
|
||||
activity.action === 'delete' ? 'bg-red-100 text-red-700' :
|
||||
'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{activity.action === 'create' ? '创建' :
|
||||
activity.action === 'update' ? '更新' :
|
||||
activity.action === 'delete' ? '删除' : '导入'}
|
||||
</span>
|
||||
<span className="font-medium">{activity.entityName || '未知'}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(activity.timestamp), 'MM-dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activity.action === 'create' && '新增了家族成员'}
|
||||
{activity.action === 'update' && '更新了成员信息'}
|
||||
{activity.action === 'delete' && '删除了成员'}
|
||||
{activity.action === 'import' && '导入了家族数据'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
暂无成员记录
|
||||
暂无修谱记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+111
-66
@@ -4,11 +4,14 @@ 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 } from "lucide-react"
|
||||
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Database } 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"
|
||||
import { ActivityLogViewer } from "@/components/settings/activity-log-viewer"
|
||||
import { upgradeDatabase } from "@/lib/db-upgrade"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { treeData, loadData, resetData } = useFamily()
|
||||
@@ -20,14 +23,15 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col font-sans">
|
||||
<SiteHeader />
|
||||
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 max-w-3xl">
|
||||
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 max-w-6xl">
|
||||
<h1 className="text-3xl font-serif font-bold mb-8">系统设置 (Settings)</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 通知设置 */}
|
||||
<NotificationSettings />
|
||||
|
||||
<Card>
|
||||
{/* 通知设置和数据备份 - 两列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<NotificationSettings />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" /> 数据备份 (Backup)
|
||||
@@ -52,66 +56,65 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" /> 数据恢复 (Restore)
|
||||
</CardTitle>
|
||||
<CardDescription>从 JSON 备份文件恢复数据。注意:这将覆盖当前的所有数据。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>警告</AlertTitle>
|
||||
<AlertDescription>导入操作不可撤销。建议在导入前先导出当前数据备份。</AlertDescription>
|
||||
</Alert>
|
||||
{/* 数据恢复和 GEDCOM - 两列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" /> 数据恢复 (Restore)
|
||||
</CardTitle>
|
||||
<CardDescription>从 JSON 备份文件恢复数据。注意:这将覆盖当前的所有数据。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>警告</AlertTitle>
|
||||
<AlertDescription>导入操作不可撤销。建议在导入前先导出当前数据备份。</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept=".json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept=".json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const json = JSON.parse(event.target?.result as string)
|
||||
if (json.members && json.rootId) {
|
||||
await loadData(json)
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const jsonStr = event.target?.result as string
|
||||
const data = JSON.parse(jsonStr)
|
||||
await loadData(data)
|
||||
setImportStatus("导入成功!")
|
||||
// Clear input so same file can be selected again if needed
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
} else {
|
||||
setImportStatus("错误:无效的数据格式")
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setImportStatus("错误:无法解析 JSON 文件")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setImportStatus("错误:无法解析文件")
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
选择文件...
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{importStatus}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
reader.readAsText(file)
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
选择文件...
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{importStatus}</span>
|
||||
</div>
|
||||
</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>
|
||||
<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
|
||||
@@ -164,14 +167,50 @@ export default function SettingsPage() {
|
||||
{gedcomStatus && <span className="text-sm text-muted-foreground">{gedcomStatus}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="h-5 w-5" /> 重置系统
|
||||
</CardTitle>
|
||||
<CardDescription>清除所有数据并恢复到初始演示状态。</CardDescription>
|
||||
{/* 数据库和系统管理 - 两列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" /> 数据库维护
|
||||
</CardTitle>
|
||||
<CardDescription>升级数据库以支持新功能(如操作日志)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
如果操作日志功能不工作,请点击下方按钮升级数据库。升级会保留所有现有数据。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (confirm("确定要升级数据库吗?这会保留所有现有数据。")) {
|
||||
try {
|
||||
await upgradeDatabase()
|
||||
alert("数据库升级成功!请刷新页面。")
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
alert("数据库升级失败:" + (error as Error).message)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
升级数据库
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" /> 危险区域 (Danger Zone)
|
||||
</CardTitle>
|
||||
<CardDescription>这些操作不可逆,请谨慎使用。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
@@ -186,7 +225,13 @@ export default function SettingsPage() {
|
||||
重置所有数据
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 操作日志 - 全宽 */}
|
||||
<ErrorBoundary>
|
||||
<ActivityLogViewer />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+47
-11
@@ -138,7 +138,7 @@ export default function TimelinePage() {
|
||||
}
|
||||
return evs
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.sort((a, b) => b.date.localeCompare(a.date)) // 倒序排列:最新的在上面
|
||||
|
||||
// -- Migration Logic --
|
||||
// Find moves between generations (Father -> Child location change)
|
||||
@@ -329,31 +329,67 @@ export default function TimelinePage() {
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<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">
|
||||
<div className="bg-card rounded-lg px-4 py-2 transition-all hover:bg-accent/5 border border-border/50">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||
<span
|
||||
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="font-serif font-bold text-base">{event.member.fullName}</span>
|
||||
<span className="text-sm text-muted-foreground">第{event.member.generation}世</span>
|
||||
<span className="font-serif font-bold">{event.member.fullName}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${event.member.gender === "male" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}>
|
||||
{event.member.gender === "male" ? "男" : "女"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">第{event.member.generation}世</span>
|
||||
|
||||
{/* 父母信息 */}
|
||||
{event.member.fatherId && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
|
||||
父:{getMember(event.member.fatherId)?.fullName || "未知"}
|
||||
</span>
|
||||
)}
|
||||
{event.member.motherId && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
|
||||
母:{getMember(event.member.motherId)?.fullName || "未知"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 配偶信息 */}
|
||||
{event.member.spouseIds && event.member.spouseIds.length > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200">
|
||||
配偶:{getMember(event.member.spouseIds[0])?.fullName || "未知"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 子女信息 */}
|
||||
{event.member.childrenIds && event.member.childrenIds.length > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-50 text-green-700 border border-green-200">
|
||||
子女:{event.member.childrenIds.length}人
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 出生地信息 */}
|
||||
{event.type === "birth" && (event.member.birthPlace || event.member.ancestralHome) && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
出生于 {event.member.birthPlace || event.member.ancestralHome}
|
||||
<span className="text-xs 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 className="text-xs 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 className="text-xs text-muted-foreground">
|
||||
⚰️{event.member.burialPlace}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground font-mono ml-auto">{event.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user