370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
"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 格式,兼容其他家谱软件(如 Ancestry、MyHeritage)。</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>
|
||
)
|
||
}
|