0.0.1.2
This commit is contained in:
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { checkPermission } from "@/lib/permissions"
|
||||
import { Role } from "@prisma/client"
|
||||
|
||||
// 获取家族树的活动日志
|
||||
export async function GET(
|
||||
@@ -20,10 +21,10 @@ export async function GET(
|
||||
const { searchParams } = new URL(req.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
|
||||
// 检查权限
|
||||
// 检查权限(所有有权限访问该家族树的人都能查看操作日志)
|
||||
const { hasPermission } = await checkPermission(session.user.id, treeId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权访问此家族树" }, { status: 403 })
|
||||
return NextResponse.json({ error: "无权访问此家族树的操作日志" }, { status: 403 })
|
||||
}
|
||||
|
||||
const logs = await prisma.activityLog.findMany({
|
||||
@@ -50,3 +51,42 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空家族树的活动日志
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId } = await params
|
||||
|
||||
// 检查权限(需要 OWNER 权限才能清空日志)
|
||||
const { hasPermission, role } = await checkPermission(session.user.id, treeId, Role.OWNER)
|
||||
if (!hasPermission || role !== Role.OWNER) {
|
||||
return NextResponse.json({ error: "只有家族树所有者才能清空操作日志" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 删除该家族树的所有日志
|
||||
const result = await prisma.activityLog.deleteMany({
|
||||
where: { treeId }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCount: result.count,
|
||||
message: `已清空 ${result.count} 条操作日志`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("清空活动日志失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "清空活动日志失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
// 获取协作者列表
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId } = await params
|
||||
|
||||
// 检查权限(所有者或协作者都可以看)
|
||||
const tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId,
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!tree) {
|
||||
return NextResponse.json({ error: "家族树不存在或无权访问" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 获取协作者
|
||||
const collaborators = await prisma.treeCollaborator.findMany({
|
||||
where: { treeId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
invitedAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ collaborators })
|
||||
} catch (error) {
|
||||
console.error("获取协作者失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取协作者失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除协作者
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const userId = searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "缺少用户ID" }, { status: 400 })
|
||||
}
|
||||
|
||||
// 检查权限:只有所有者可以移除协作者,或者是协作者自己退出
|
||||
const tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId
|
||||
}
|
||||
})
|
||||
|
||||
if (!tree) {
|
||||
return NextResponse.json({ error: "家族树不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
const isOwner = tree.ownerId === session.user.id
|
||||
const isSelf = userId === session.user.id
|
||||
|
||||
if (!isOwner && !isSelf) {
|
||||
return NextResponse.json({ error: "无权移除此协作者" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 移除协作者
|
||||
await prisma.treeCollaborator.deleteMany({
|
||||
where: {
|
||||
treeId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("移除协作者失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "移除协作者失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { hash } from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
import { sendInvitationEmail } from "@/lib/mail"
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
role: z.enum(["EDITOR", "VIEWER"], {
|
||||
errorMap: () => ({ message: "请选择有效的角色" }),
|
||||
}),
|
||||
password: z.string().min(6, "密码至少需要6位").optional(),
|
||||
})
|
||||
|
||||
// 邀请协作者
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId } = await params
|
||||
const body = await req.json()
|
||||
const { email, role, password } = inviteSchema.parse(body)
|
||||
|
||||
// 1. 检查权限
|
||||
const tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId,
|
||||
ownerId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
if (!tree) {
|
||||
return NextResponse.json({ error: "家族树不存在或无权邀请" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 2. 检查目标用户是否存在
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
let isNewUser = false
|
||||
|
||||
// 3. 如果用户不存在,创建新用户
|
||||
if (!user) {
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: "新用户必须设置初始密码" }, { status: 400 })
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password, 12)
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name: email.split('@')[0], // 使用邮箱前缀作为默认用户名
|
||||
}
|
||||
})
|
||||
isNewUser = true
|
||||
}
|
||||
|
||||
// 4. 检查是否已经是协作者或所有者
|
||||
if (user.id === tree.ownerId) {
|
||||
return NextResponse.json({ error: "不能邀请自己" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingCollaborator = await prisma.treeCollaborator.findUnique({
|
||||
where: {
|
||||
treeId_userId: {
|
||||
treeId,
|
||||
userId: user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (existingCollaborator) {
|
||||
// 如果已存在,更新角色
|
||||
await prisma.treeCollaborator.update({
|
||||
where: { id: existingCollaborator.id },
|
||||
data: { role }
|
||||
})
|
||||
return NextResponse.json({
|
||||
message: "用户已是协作者,角色已更新",
|
||||
isNewUser: false
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 添加协作者
|
||||
await prisma.treeCollaborator.create({
|
||||
data: {
|
||||
treeId,
|
||||
userId: user.id,
|
||||
role,
|
||||
invitedBy: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// 6. 发送邀请邮件
|
||||
try {
|
||||
await sendInvitationEmail(
|
||||
email,
|
||||
session.user.name || session.user.email || "用户",
|
||||
tree.name,
|
||||
role,
|
||||
isNewUser ? password : undefined
|
||||
)
|
||||
} catch (emailError) {
|
||||
console.error("发送邀请邮件失败:", emailError)
|
||||
// 邮件发送失败不影响邀请结果,但记录日志
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: isNewUser ? "新用户已创建并添加为协作者,邮件已发送" : "用户已添加为协作者,邮件已发送",
|
||||
isNewUser
|
||||
}, { status: 201 })
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.errors[0].message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error("邀请协作者失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "邀请协作者失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,14 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
console.log('👥 GET /api/trees/[treeId]/members 返回数据:', {
|
||||
treeId,
|
||||
userId: session.user.id,
|
||||
membersCount: members.length,
|
||||
rootId,
|
||||
members: members.map(m => ({ id: m.id, fullName: m.fullName, generation: m.generation }))
|
||||
})
|
||||
|
||||
return NextResponse.json({ members, rootId })
|
||||
} catch (error) {
|
||||
console.error("获取成员失败:", error)
|
||||
|
||||
@@ -52,7 +52,34 @@ export async function GET(
|
||||
return NextResponse.json({ error: "家族树不存在或无权访问" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ tree })
|
||||
// 计算当前用户角色
|
||||
let currentUserRole = "VIEWER"
|
||||
if (tree.ownerId === session.user.id) {
|
||||
currentUserRole = "OWNER"
|
||||
} else {
|
||||
const collaborator = await prisma.treeCollaborator.findUnique({
|
||||
where: {
|
||||
treeId_userId: {
|
||||
treeId: tree.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
if (collaborator) {
|
||||
currentUserRole = collaborator.role
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🌳 GET /api/trees/[treeId] 返回数据:', {
|
||||
treeId,
|
||||
userId: session.user.id,
|
||||
treeName: tree.name,
|
||||
currentUserRole,
|
||||
membersCount: tree._count.members,
|
||||
collaboratorsCount: tree._count.collaborators
|
||||
})
|
||||
|
||||
return NextResponse.json({ tree: { ...tree, currentUserRole } })
|
||||
} catch (error) {
|
||||
console.error("获取家族树失败:", error)
|
||||
return NextResponse.json(
|
||||
|
||||
+45
-24
@@ -18,37 +18,58 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const trees = await prisma.familyTree.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 1. 查询拥有的家族树
|
||||
const ownedTrees = await prisma.familyTree.findMany({
|
||||
where: { ownerId: session.user.id },
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
select: { id: true, name: true, email: true }
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
collaborators: true,
|
||||
select: { members: true, collaborators: true }
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
})
|
||||
|
||||
// 2. 查询协作的家族树
|
||||
const collaboratedRelations = await prisma.treeCollaborator.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
tree: {
|
||||
include: {
|
||||
owner: {
|
||||
select: { id: true, name: true, email: true }
|
||||
},
|
||||
_count: {
|
||||
select: { members: true, collaborators: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
orderBy: { invitedAt: 'desc' }
|
||||
})
|
||||
|
||||
// 3. 格式化数据
|
||||
const formattedOwnedTrees = ownedTrees.map(tree => ({
|
||||
...tree,
|
||||
currentUserRole: "OWNER"
|
||||
}))
|
||||
|
||||
const formattedCollaboratedTrees = collaboratedRelations.map(relation => ({
|
||||
...relation.tree,
|
||||
currentUserRole: relation.role
|
||||
}))
|
||||
|
||||
// 4. 合并列表
|
||||
const trees = [...formattedOwnedTrees, ...formattedCollaboratedTrees]
|
||||
|
||||
console.log('🌳 GET /api/trees 返回数据:', {
|
||||
userId: session.user.id,
|
||||
ownedCount: formattedOwnedTrees.length,
|
||||
collaboratedCount: formattedCollaboratedTrees.length,
|
||||
totalCount: trees.length,
|
||||
trees: trees.map(t => ({ id: t.id, name: t.name, role: t.currentUserRole }))
|
||||
})
|
||||
|
||||
return NextResponse.json({ trees })
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
// 获取当前用户的所有相关活动日志(拥有的或参与的家族树)
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
|
||||
// 1. 查找用户拥有或协作的所有家族树 ID
|
||||
const ownedTrees = await prisma.familyTree.findMany({
|
||||
where: { ownerId: session.user.id },
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
const collaboratedTrees = await prisma.treeCollaborator.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { treeId: true }
|
||||
})
|
||||
|
||||
const treeIds = [
|
||||
...ownedTrees.map(t => t.id),
|
||||
...collaboratedTrees.map(c => c.treeId)
|
||||
]
|
||||
|
||||
// 2. 查询这些家族树的日志
|
||||
const logs = await prisma.activityLog.findMany({
|
||||
where: {
|
||||
treeId: { in: treeIds }
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
tree: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ logs })
|
||||
} catch (error) {
|
||||
console.error("获取用户活动日志失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取用户活动日志失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -6,6 +6,7 @@ import "./globals.css"
|
||||
import { FamilyProvider } from "@/context/family-context"
|
||||
import { PWAProvider } from "@/components/pwa/pwa-provider"
|
||||
import { SessionProvider } from "@/components/providers/session-provider"
|
||||
import { DialogProvider } from "@/components/ui/alert-dialog-custom"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
||||
@@ -48,11 +49,13 @@ export default function RootLayout({
|
||||
<html lang="zh-CN">
|
||||
<body className={`font-sans antialiased`}>
|
||||
<SessionProvider>
|
||||
<FamilyProvider>
|
||||
<PWAProvider>
|
||||
{children}
|
||||
</PWAProvider>
|
||||
</FamilyProvider>
|
||||
<DialogProvider>
|
||||
<FamilyProvider>
|
||||
<PWAProvider>
|
||||
{children}
|
||||
</PWAProvider>
|
||||
</FamilyProvider>
|
||||
</DialogProvider>
|
||||
</SessionProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
@@ -13,10 +13,12 @@ import type { FamilyMember } from "@/types/family"
|
||||
import { db } from "@/lib/db"
|
||||
import { PhotoGallery } from "@/components/members/photo-gallery"
|
||||
import { StoryManager } from "@/components/members/story-manager"
|
||||
import { useDialog } from "@/components/ui/alert-dialog-custom"
|
||||
|
||||
export default function MemberProfilePage() {
|
||||
const { id } = useParams()
|
||||
const { getMember, updateMember, deleteMember } = useFamily()
|
||||
const { showConfirm } = useDialog()
|
||||
const router = useRouter()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | undefined>(undefined)
|
||||
@@ -55,8 +57,9 @@ export default function MemberProfilePage() {
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("确定要删除这位成员吗?这将同时删除其关联关系。")) {
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await showConfirm("确定要删除这位成员吗?这将同时删除其关联关系。", "删除成员", "destructive")
|
||||
if (confirmed) {
|
||||
deleteMember(member.id)
|
||||
router.push("/members")
|
||||
}
|
||||
@@ -94,7 +97,7 @@ export default function MemberProfilePage() {
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
member.gender === 'female' ? (
|
||||
member.gender === 'FEMALE' ? (
|
||||
<CircleUserRound className="h-20 w-20 text-pink-400" />
|
||||
) : (
|
||||
<CircleUser className="h-20 w-20 text-blue-400" />
|
||||
|
||||
@@ -14,13 +14,18 @@ export default function NewMemberPage() {
|
||||
|
||||
const existingMembers = Object.values(treeData.members)
|
||||
|
||||
const handleSave = (newMember: FamilyMember) => {
|
||||
addMember({
|
||||
...newMember,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
router.push(`/members/${newMember.id}`)
|
||||
const handleSave = async (newMember: FamilyMember) => {
|
||||
try {
|
||||
await addMember({
|
||||
...newMember,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
router.push(`/members/${newMember.id}`)
|
||||
} catch (error) {
|
||||
console.error('添加成员失败:', error)
|
||||
alert('添加成员失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { Database, AlertTriangle, CheckCircle2 } from "lucide-react"
|
||||
import { migrateFromIndexedDB, clearIndexedDB } from "@/lib/migrate-data"
|
||||
|
||||
export default function MigratePage() {
|
||||
const router = useRouter()
|
||||
const [status, setStatus] = useState<"idle" | "migrating" | "success" | "error">("idle")
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleMigrate = async () => {
|
||||
setStatus("migrating")
|
||||
setProgress(0)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
// 模拟进度
|
||||
setProgress(20)
|
||||
|
||||
// 这里需要先创建一个家族树,然后获取 treeId 和 userId
|
||||
// 为了演示,我们先创建一个默认的家族树
|
||||
const treeResponse = await fetch("/api/trees", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "我的家族树",
|
||||
description: "从 IndexedDB 迁移的数据"
|
||||
})
|
||||
})
|
||||
|
||||
if (!treeResponse.ok) {
|
||||
throw new Error("创建家族树失败")
|
||||
}
|
||||
|
||||
const { tree } = await treeResponse.json()
|
||||
setProgress(40)
|
||||
|
||||
// 执行迁移
|
||||
const migrateResult = await migrateFromIndexedDB(tree.id, tree.ownerId)
|
||||
setProgress(80)
|
||||
|
||||
setResult(migrateResult)
|
||||
setProgress(100)
|
||||
setStatus("success")
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || "迁移失败")
|
||||
setStatus("error")
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearIndexedDB = async () => {
|
||||
if (!confirm("确定要清空 IndexedDB 数据吗?此操作不可撤销!")) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await clearIndexedDB()
|
||||
alert("IndexedDB 数据已清空")
|
||||
} catch (err: any) {
|
||||
alert("清空失败: " + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<SiteHeader />
|
||||
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 max-w-3xl">
|
||||
<h1 className="text-3xl font-serif font-bold mb-8">数据迁移</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
从 IndexedDB 迁移到 PostgreSQL
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
将本地浏览器中的家族数据迁移到云端数据库
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>注意</AlertTitle>
|
||||
<AlertDescription>
|
||||
迁移前请确保:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>已登录账号</li>
|
||||
<li>本地有需要迁移的数据</li>
|
||||
<li>网络连接正常</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{status === "migrating" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>迁移进度</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "success" && result && (
|
||||
<Alert className="border-green-500 bg-green-50">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<AlertTitle className="text-green-900">迁移成功!</AlertTitle>
|
||||
<AlertDescription className="text-green-800">
|
||||
成功迁移 {result.count} / {result.total} 个成员
|
||||
{result.errors > 0 && `,失败 ${result.errors} 个`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>迁移失败</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleMigrate}
|
||||
disabled={status === "migrating"}
|
||||
className="flex-1"
|
||||
>
|
||||
{status === "migrating" ? "迁移中..." : "开始迁移"}
|
||||
</Button>
|
||||
|
||||
{status === "success" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
查看数据
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">危险操作</CardTitle>
|
||||
<CardDescription>
|
||||
迁移成功后,可以选择清空本地数据
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearIndexedDB}
|
||||
disabled={status !== "success"}
|
||||
>
|
||||
清空 IndexedDB 数据
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+38
-10
@@ -14,6 +14,7 @@ import { StatisticsCharts } from "@/components/dashboard/statistics-charts"
|
||||
import { format } from "date-fns"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { CollaboratorDialog } from "@/components/tree/collaborator-dialog"
|
||||
|
||||
interface ActivityLog {
|
||||
id: string
|
||||
@@ -62,6 +63,14 @@ export default function DashboardPage() {
|
||||
const members = Object.values(treeData.members)
|
||||
const totalMembers = members.length
|
||||
|
||||
console.log('📈 首页统计数据计算:', {
|
||||
treeDataKeys: Object.keys(treeData),
|
||||
membersObjectKeys: Object.keys(treeData.members),
|
||||
membersCount: totalMembers,
|
||||
members: members,
|
||||
treeData: treeData
|
||||
})
|
||||
|
||||
// 计算最大代数(处理空数组的情况)
|
||||
const generations = members.map(m => m.generation || 0)
|
||||
const maxGeneration = generations.length > 0 ? Math.max(...generations) : 0
|
||||
@@ -140,27 +149,46 @@ export default function DashboardPage() {
|
||||
<main className="flex-1 container mx-auto py-8 px-4 md:px-6">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif font-bold text-foreground">
|
||||
{currentTree?.name || '家族族谱'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-serif font-bold text-foreground">
|
||||
{currentTree?.name || '家族族谱'}
|
||||
</h1>
|
||||
{currentTree?.currentUserRole && (
|
||||
<span className={`text-xs px-2 py-1 rounded-full border font-sans font-normal translate-y-[2px] ${
|
||||
currentTree.currentUserRole === 'OWNER'
|
||||
? 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
: currentTree.currentUserRole === 'EDITOR'
|
||||
? 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}`}>
|
||||
{currentTree.currentUserRole === 'OWNER' ? '所有者' :
|
||||
currentTree.currentUserRole === 'EDITOR' ? '编辑者' : '查看者'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{currentTree?.description || '记录家族历史,传承家族文化'}
|
||||
{stats.maxGeneration > 0 && ` · 第 ${stats.maxGeneration} 代传人`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{session && currentTree?.ownerId === session.user?.id && (
|
||||
<CollaboratorDialog />
|
||||
)}
|
||||
<Link href="/tree">
|
||||
<Button variant="outline" className="gap-2 bg-transparent">
|
||||
<Network className="h-4 w-4" />
|
||||
查看世系图
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/members/new">
|
||||
<Button className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
添加成员
|
||||
</Button>
|
||||
</Link>
|
||||
{(currentTree?.currentUserRole === "OWNER" || currentTree?.currentUserRole === "EDITOR") && (
|
||||
<Link href="/members/new">
|
||||
<Button className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
添加成员
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+150
-181
@@ -8,13 +8,12 @@ import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Da
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { useRef, useState, useEffect } 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"
|
||||
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"
|
||||
|
||||
interface UserProfile {
|
||||
id: string
|
||||
@@ -33,6 +32,7 @@ interface UserProfile {
|
||||
export default function SettingsPage() {
|
||||
const { treeData, loadData, resetData } = useFamily()
|
||||
const { data: session } = useSession()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
const [importStatus, setImportStatus] = useState<string>("")
|
||||
const [gedcomStatus, setGedcomStatus] = useState<string>("")
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
|
||||
@@ -94,7 +94,7 @@ export default function SettingsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserIcon className="h-5 w-5" /> 用户信息
|
||||
<UserIcon className="h-5 w-5" /> 系统设置
|
||||
</CardTitle>
|
||||
<CardDescription>您的账号信息和角色</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -142,6 +142,26 @@ export default function SettingsPage() {
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -149,83 +169,87 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知设置和数据备份 - 两列布局 */}
|
||||
<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)
|
||||
</CardTitle>
|
||||
<CardDescription>将家族数据导出为 JSON 文件进行本地保存。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 数据恢复和 GEDCOM - 两列布局 */}
|
||||
{/* 数据管理和 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)
|
||||
<Database className="h-5 w-5" /> 数据管理 (Data Management)
|
||||
</CardTitle>
|
||||
<CardDescription>从 JSON 备份文件恢复数据。注意:这将覆盖当前的所有数据。</CardDescription>
|
||||
<CardDescription>备份和恢复家族树数据。</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
|
||||
|
||||
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)
|
||||
<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>
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
选择文件...
|
||||
>
|
||||
导出数据 (Export JSON)
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{importStatus}</span>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
@@ -237,116 +261,61 @@ export default function SettingsPage() {
|
||||
</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
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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>
|
||||
|
||||
<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("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>
|
||||
</div>
|
||||
|
||||
{/* 数据库和系统管理 - 两列布局 */}
|
||||
<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
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (confirm("确定要重置吗?所有更改将丢失。")) {
|
||||
await resetData()
|
||||
alert("数据已重置为演示状态。")
|
||||
}
|
||||
}}
|
||||
>
|
||||
重置所有数据
|
||||
</Button>
|
||||
</CardContent>
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user