Files
freedakgmail b436ae0680 0.2.0.2
2025-11-29 22:29:48 +08:00

370 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
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, Settings, User, Users } from "lucide-react"
import Link from "next/link"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { useRef, useState, useEffect } from "react"
import { exportToGedcom, importFromGedcom } from "@/lib/gedcom"
import { ActivityLogViewer } from "@/components/settings/activity-log-viewer"
import { ErrorBoundary } from "@/components/error-boundary"
import { useSession } from "next-auth/react"
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
email: string
name: string | null
isAdmin: boolean
createdAt: string
updatedAt: string
ownedTreesCount: number
collaborations: Array<{
treeId: string
treeName: string
role: string
}>
}
export default function SettingsPage() {
const { treeData, currentTree, loadData, resetData } = useFamily()
const role = currentTree?.currentUserRole
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()
}
if (session?.user?.email) {
return session.user.email.charAt(0).toUpperCase()
}
return "U"
}
// 获取用户资料
useEffect(() => {
if (session?.user?.id) {
fetch('/api/user/profile')
.then(res => res.json())
.then(data => {
if (data.user) {
setUserProfile(data.user)
}
})
.catch(err => console.error('获取用户资料失败:', err))
}
}, [session?.user?.id])
const getRoleText = (role: string) => {
const roleMap: Record<string, string> = {
'OWNER': '所有者',
'EDITOR': '编辑者',
'VIEWER': '查看者'
}
return roleMap[role] || role
}
const getRoleBadgeColor = (role: string) => {
const colorMap: Record<string, string> = {
'OWNER': 'bg-purple-100 text-purple-700 border-purple-200',
'EDITOR': 'bg-blue-100 text-blue-700 border-blue-200',
'VIEWER': 'bg-gray-100 text-gray-700 border-gray-200'
}
return colorMap[role] || 'bg-gray-100 text-gray-700'
}
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {
return null
}
return (
<div className="h-screen bg-background flex flex-col font-sans overflow-hidden">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 max-w-6xl flex flex-col overflow-hidden">
<div className="flex items-center justify-between mb-8 flex-shrink-0">
<h1 className="text-3xl font-serif font-bold"></h1>
{userProfile?.isAdmin && (
<Link href="/admin">
<Button variant="outline" className="gap-2">
<Users className="h-4 w-4" />
</Button>
</Link>
)}
</div>
<div className="space-y-6 overflow-y-auto flex-1 pb-8">
{/* 用户信息和密码修改 - 两列布局 */}
{session && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserIcon className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-border">
<AvatarFallback className="text-2xl">{getUserInitial()}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="text-lg font-semibold">{session.user?.name || "用户"}</h3>
<p className="text-sm text-muted-foreground">{session.user?.email}</p>
<p className="text-xs text-muted-foreground mt-1">
ID: {session.user?.id}
</p>
</div>
</div>
{userProfile && (userProfile.ownedTreesCount > 0 || userProfile.collaborations.length > 0) && (
<div className="space-y-3 pt-3 border-t">
<p className="text-sm font-medium"></p>
<div className="space-y-2">
{/* 显示拥有的家族树(所有者) */}
{userProfile.ownedTreesCount > 0 && (
<div className="text-sm text-muted-foreground">
<span className={`inline-flex items-center px-2 py-1 rounded-full border text-xs ${getRoleBadgeColor('OWNER')}`}>
{getRoleText('OWNER')}
</span>
<span className="ml-2">{userProfile.ownedTreesCount} </span>
</div>
)}
{/* 显示协作的家族树 */}
{userProfile.collaborations.length > 0 && (
<div className="space-y-2">
{userProfile.collaborations.map((collab) => (
<div key={collab.treeId} className="flex items-center justify-between text-sm p-2 rounded-md bg-muted/50">
<span className="text-muted-foreground truncate flex-1">{collab.treeName}</span>
<span className={`text-xs px-2 py-1 rounded-full border ${getRoleBadgeColor(collab.role)}`}>
{getRoleText(collab.role)}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
<div className="pt-3 border-t">
<p className="text-sm font-medium text-destructive mb-2 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
</p>
{permissions.canClearData(role) && (
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={async () => {
const confirmed = await showConfirm("确定要重置吗?所有更改将丢失。", "重置数据", "destructive")
if (confirmed) {
await resetData()
await showAlert("数据已重置为演示状态。", "成功")
}
}}
>
</Button>
)}
</div>
</CardContent>
</Card>
<ChangePassword />
</div>
)}
{/* 数据管理和 GEDCOM - 两列布局 */}
<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-6">
{/* 备份部分 */}
<div className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-2">
<Download className="h-4 w-4" />
</h3>
<p className="text-sm text-muted-foreground"> JSON </p>
{permissions.canExport(role) && (
<Button
onClick={() => {
const dataStr = JSON.stringify(treeData, null, 2)
const blob = new Blob([dataStr], { type: "application/json" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `family_tree_backup_${new Date().toISOString().split("T")[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
>
(Export JSON)
</Button>
)}
</div>
<div className="border-t" />
{/* 恢复部分 */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium flex items-center gap-2">
<Upload className="h-4 w-4" />
</h3>
<p className="text-sm text-muted-foreground mt-1"> JSON </p>
</div>
{permissions.canImport(role) && (
<div className="flex items-center gap-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept=".json"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
const confirmed = await showConfirm("恢复数据将覆盖当前所有数据,确定继续吗?", "恢复数据", "destructive")
if (!confirmed) {
if (fileInputRef.current) fileInputRef.current.value = ""
return
}
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("导入成功!")
if (fileInputRef.current) fileInputRef.current.value = ""
} catch (err) {
console.error(err)
setImportStatus("错误:无法解析 JSON 文件")
}
}
reader.readAsText(file)
}}
/>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
...
</Button>
<span className="text-sm text-muted-foreground">{importStatus}</span>
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" /> GEDCOM
</CardTitle>
<CardDescription>/ GEDCOM AncestryMyHeritage</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4">
{permissions.canExport(role) && (
<Button
variant="outline"
className="justify-start"
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>
)}
{permissions.canImport(role) && (
<div className="flex items-center gap-2">
<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("导入成功!")
if (gedcomInputRef.current) gedcomInputRef.current.value = ""
} catch (err) {
console.error(err)
setGedcomStatus("错误:无法解析文件")
}
}
reader.readAsText(file)
}}
/>
<Button variant="outline" className="justify-start" 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>
)}
</div>
</CardContent>
</Card>
</div>
{/* 操作日志 - 全宽 */}
<ErrorBoundary>
<ActivityLogViewer />
</ErrorBoundary>
</div>
</main>
</div>
)
}