0.0.1.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().min(6, "密码至少6个字符"),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { email, password, name } = registerSchema.parse(body)
|
||||
|
||||
// 检查用户是否已存在
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "该邮箱已被注册" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
// 创建用户
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: "注册成功",
|
||||
user
|
||||
})
|
||||
} 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { checkPermission } from "@/lib/permissions"
|
||||
|
||||
// 获取家族树的活动日志
|
||||
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 { 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 })
|
||||
}
|
||||
|
||||
const logs = await prisma.activityLog.findMany({
|
||||
where: { treeId },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ logs })
|
||||
} catch (error) {
|
||||
console.error("获取活动日志失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取活动日志失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
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 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
|
||||
|
||||
// 检查权限(需要 OWNER 权限)
|
||||
const { hasPermission } = await checkPermission(session.user.id, treeId, Role.OWNER)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权限导入数据" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { members, rootId } = body
|
||||
|
||||
// 删除现有成员
|
||||
await prisma.familyMember.deleteMany({
|
||||
where: { treeId }
|
||||
})
|
||||
|
||||
// 批量创建新成员
|
||||
const memberArray = Object.values(members)
|
||||
await prisma.familyMember.createMany({
|
||||
data: memberArray.map((member: any) => ({
|
||||
...member,
|
||||
treeId,
|
||||
}))
|
||||
})
|
||||
|
||||
// 更新家族树的 rootMemberId
|
||||
if (rootId) {
|
||||
await prisma.familyTree.update({
|
||||
where: { id: treeId },
|
||||
data: { rootMemberId: rootId }
|
||||
})
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
await prisma.activityLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
userId: session.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'SETTINGS',
|
||||
entityName: '导入家族数据',
|
||||
changes: {
|
||||
memberCount: memberArray.length
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
memberCount: memberArray.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("导入数据失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "导入数据失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
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(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId, memberId } = await params
|
||||
|
||||
// 检查权限
|
||||
const { hasPermission } = await checkPermission(session.user.id, treeId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权访问此家族树" }, { status: 403 })
|
||||
}
|
||||
|
||||
const member = await prisma.familyMember.findFirst({
|
||||
where: {
|
||||
id: memberId,
|
||||
treeId,
|
||||
}
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ member })
|
||||
} catch (error) {
|
||||
console.error("获取成员失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取成员失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新成员
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId, memberId } = await params
|
||||
|
||||
// 检查权限(需要 EDITOR 权限)
|
||||
const { hasPermission } = await checkPermission(session.user.id, treeId, Role.EDITOR)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权限修改成员" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
// 检查成员是否存在
|
||||
const existingMember = await prisma.familyMember.findFirst({
|
||||
where: {
|
||||
id: memberId,
|
||||
treeId,
|
||||
}
|
||||
})
|
||||
|
||||
if (!existingMember) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
const member = await prisma.familyMember.update({
|
||||
where: { id: memberId },
|
||||
data: {
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
await prisma.activityLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
userId: session.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'MEMBER',
|
||||
entityId: member.id,
|
||||
entityName: member.fullName,
|
||||
changes: body,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ member })
|
||||
} catch (error) {
|
||||
console.error("更新成员失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "更新成员失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除成员
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ treeId: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { treeId, memberId } = await params
|
||||
|
||||
// 检查权限(需要 EDITOR 权限)
|
||||
const { hasPermission } = await checkPermission(session.user.id, treeId, Role.EDITOR)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权限删除成员" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 检查成员是否存在
|
||||
const existingMember = await prisma.familyMember.findFirst({
|
||||
where: {
|
||||
id: memberId,
|
||||
treeId,
|
||||
}
|
||||
})
|
||||
|
||||
if (!existingMember) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.familyMember.delete({
|
||||
where: { id: memberId }
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
await prisma.activityLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
userId: session.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'MEMBER',
|
||||
entityId: memberId,
|
||||
entityName: existingMember.fullName,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("删除成员失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "删除成员失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { checkPermission } from "@/lib/permissions"
|
||||
import { Role, Gender } from "@prisma/client"
|
||||
import { z } from "zod"
|
||||
|
||||
const createMemberSchema = z.object({
|
||||
surname: z.string().min(1),
|
||||
givenName: z.string().min(1),
|
||||
fullName: z.string().min(1),
|
||||
gender: z.nativeEnum(Gender),
|
||||
generation: z.number().int().positive(),
|
||||
birthDate: z.string().optional(),
|
||||
deathDate: z.string().optional(),
|
||||
fatherId: z.string().optional(),
|
||||
motherId: z.string().optional(),
|
||||
spouseIds: z.array(z.string()).optional(),
|
||||
// ... 其他字段
|
||||
})
|
||||
|
||||
// 获取家族树的所有成员
|
||||
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 { hasPermission } = await checkPermission(session.user.id, treeId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权访问此家族树" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 获取家族树信息
|
||||
const tree = await prisma.familyTree.findUnique({
|
||||
where: { id: treeId },
|
||||
select: { rootMemberId: true }
|
||||
})
|
||||
|
||||
const members = await prisma.familyMember.findMany({
|
||||
where: { treeId },
|
||||
orderBy: [
|
||||
{ generation: 'asc' },
|
||||
{ createdAt: 'asc' }
|
||||
]
|
||||
})
|
||||
|
||||
// 如果没有设置 rootMemberId,尝试找到第一代成员作为根
|
||||
let rootId = tree?.rootMemberId
|
||||
if (!rootId && members.length > 0) {
|
||||
const firstGenMember = members.find((m: any) => m.generation === 1)
|
||||
if (firstGenMember) {
|
||||
rootId = firstGenMember.id
|
||||
// 更新家族树的 rootMemberId
|
||||
await prisma.familyTree.update({
|
||||
where: { id: treeId },
|
||||
data: { rootMemberId: firstGenMember.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ members, rootId })
|
||||
} catch (error) {
|
||||
console.error("获取成员失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取成员失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新成员
|
||||
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
|
||||
|
||||
// 检查权限(需要 EDITOR 权限)
|
||||
const { hasPermission } = await checkPermission(session.user.id, treeId, Role.EDITOR)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "无权限添加成员" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const data = createMemberSchema.parse(body)
|
||||
|
||||
const member = await prisma.familyMember.create({
|
||||
data: {
|
||||
...data,
|
||||
treeId,
|
||||
}
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
await prisma.activityLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
userId: session.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'MEMBER',
|
||||
entityId: member.id,
|
||||
entityName: member.fullName,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ member }, { 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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
|
||||
console.log('API /trees/[treeId] - Requested treeId:', treeId)
|
||||
|
||||
const tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId,
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
collaborators: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!tree) {
|
||||
console.log('API /trees/[treeId] - Tree not found for id:', treeId)
|
||||
return NextResponse.json({ error: "家族树不存在或无权访问" }, { status: 404 })
|
||||
}
|
||||
|
||||
console.log('API /trees/[treeId] - Returning tree:', tree.name, tree.id)
|
||||
return NextResponse.json({ tree })
|
||||
} catch (error) {
|
||||
console.error("获取家族树失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取家族树失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新家族树信息
|
||||
export async function PATCH(
|
||||
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 { name, description } = body
|
||||
|
||||
// 检查权限
|
||||
const tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId,
|
||||
ownerId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
if (!tree) {
|
||||
return NextResponse.json({ error: "家族树不存在或无权修改" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 更新家族树
|
||||
const updatedTree = await prisma.familyTree.update({
|
||||
where: { id: treeId },
|
||||
data: {
|
||||
name: name || tree.name,
|
||||
description: description !== undefined ? description : tree.description,
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ tree: updatedTree })
|
||||
} 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 tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId,
|
||||
ownerId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
if (!tree) {
|
||||
return NextResponse.json({ error: "家族树不存在或无权删除" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 删除家族树(会级联删除所有成员和相关数据)
|
||||
await prisma.familyTree.delete({
|
||||
where: { id: treeId }
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("删除家族树失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "删除家族树失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { z } from "zod"
|
||||
|
||||
const createTreeSchema = z.object({
|
||||
name: z.string().min(1, "家族树名称不能为空"),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
// 获取用户的所有家族树
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const trees = await prisma.familyTree.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
collaborators: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ trees })
|
||||
} catch (error) {
|
||||
console.error("获取家族树失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取家族树失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新家族树
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { name, description } = createTreeSchema.parse(body)
|
||||
|
||||
const tree = await prisma.familyTree.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
ownerId: session.user.id,
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ tree }, { 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "请输入当前密码"),
|
||||
newPassword: z.string().min(6, "新密码至少需要6个字符"),
|
||||
confirmPassword: z.string().min(1, "请确认新密码"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "两次输入的密码不一致",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { currentPassword, newPassword } = changePasswordSchema.parse(body)
|
||||
|
||||
// 获取用户当前密码
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { password: true }
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
const isValid = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "当前密码错误" }, { status: 400 })
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
// 更新密码
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword }
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: "密码修改成功" })
|
||||
} 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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 user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 获取用户拥有的家族树数量
|
||||
const ownedTreesCount = await prisma.familyTree.count({
|
||||
where: { ownerId: session.user.id }
|
||||
})
|
||||
|
||||
// 获取用户作为协作者的家族树
|
||||
const collaborations = await prisma.treeCollaborator.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
tree: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
...user,
|
||||
ownedTreesCount,
|
||||
collaborations: collaborations.map((c) => ({
|
||||
treeId: c.tree.id,
|
||||
treeName: c.tree.name,
|
||||
role: c.role,
|
||||
}))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("获取用户信息失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取用户信息失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("密码至少需要6个字符")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "注册失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 注册成功,跳转到登录页
|
||||
router.push("/auth/signin?registered=true")
|
||||
} catch (err) {
|
||||
setError("注册失败,请稍后重试")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-secondary/5 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<BookOpen className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-serif">注册</CardTitle>
|
||||
<CardDescription>
|
||||
创建您的华夏谱账号
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert className="bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||
<AlertDescription className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 系统已有测试账号,建议直接<Link href="/auth/signin" className="underline font-semibold">登录</Link>使用
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="张三"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="至少6个字符"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</Button>
|
||||
|
||||
<div className="text-sm text-center text-muted-foreground">
|
||||
已有账号?{" "}
|
||||
<Link href="/auth/signin" className="text-primary hover:underline">
|
||||
立即登录
|
||||
</Link>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { signIn, signOut } from "next-auth/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
export default function SignInPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("wang@example.com")
|
||||
const [password, setPassword] = useState("123456")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// 先退出登录,清除旧的 session
|
||||
await signOut({ redirect: false })
|
||||
|
||||
// 等待一小段时间确保 session 被清除
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 清除所有 localStorage 和 sessionStorage
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
|
||||
// 然后重新登录
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError("邮箱或密码错误")
|
||||
setLoading(false)
|
||||
} else {
|
||||
// 强制完全刷新页面,清除所有缓存
|
||||
window.location.replace("/")
|
||||
}
|
||||
} catch (err) {
|
||||
setError("登录失败,请稍后重试")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-secondary/5 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<BookOpen className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-serif">登录</CardTitle>
|
||||
<CardDescription>
|
||||
登录到华夏谱家族树系统
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert className="bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||
<AlertDescription className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">💡 测试账号:</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>王先生: wang@example.com / 123456</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEmail("wang@example.com")
|
||||
setPassword("123456")
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>虞女士: yu@example.com / 123456</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEmail("yu@example.com")
|
||||
setPassword("123456")
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
|
||||
<div className="text-sm text-center text-muted-foreground">
|
||||
还没有账号?{" "}
|
||||
<Link href="/auth/register" className="text-primary hover:underline">
|
||||
立即注册
|
||||
</Link>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+8
-5
@@ -5,6 +5,7 @@ import { Analytics } from "@vercel/analytics/next"
|
||||
import "./globals.css"
|
||||
import { FamilyProvider } from "@/context/family-context"
|
||||
import { PWAProvider } from "@/components/pwa/pwa-provider"
|
||||
import { SessionProvider } from "@/components/providers/session-provider"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
||||
@@ -46,11 +47,13 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`font-sans antialiased`}>
|
||||
<FamilyProvider>
|
||||
<PWAProvider>
|
||||
{children}
|
||||
</PWAProvider>
|
||||
</FamilyProvider>
|
||||
<SessionProvider>
|
||||
<FamilyProvider>
|
||||
<PWAProvider>
|
||||
{children}
|
||||
</PWAProvider>
|
||||
</FamilyProvider>
|
||||
</SessionProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
+53
-11
@@ -9,35 +9,72 @@ 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, useState, useEffect } from "react"
|
||||
import { useMemo, useState, useEffect, useRef } 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"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
interface ActivityLog {
|
||||
id: string
|
||||
action: string
|
||||
entityType: string
|
||||
entityId?: string | null
|
||||
entityName?: string | null
|
||||
timestamp: string
|
||||
user?: {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { treeData } = useFamily()
|
||||
const { treeData, currentTree, isLoading } = useFamily()
|
||||
const { data: session } = useSession()
|
||||
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
|
||||
|
||||
// 调试日志
|
||||
console.log('Dashboard - treeData:', treeData, 'currentTree:', currentTree?.name, 'isLoading:', isLoading)
|
||||
|
||||
// 加载最近的操作日志
|
||||
useEffect(() => {
|
||||
getActivityLogs(10).then(setRecentActivities)
|
||||
}, [treeData])
|
||||
if (currentTree?.id && session?.user?.id) {
|
||||
fetch(`/api/trees/${currentTree.id}/activity-logs?limit=10`)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error('获取活动日志失败')
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
if (data.logs) {
|
||||
setRecentActivities(data.logs)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('获取活动日志失败:', err)
|
||||
setRecentActivities([])
|
||||
})
|
||||
} else {
|
||||
setRecentActivities([])
|
||||
}
|
||||
}, [currentTree?.id, session?.user?.id])
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
const members = Object.values(treeData.members)
|
||||
const totalMembers = members.length
|
||||
|
||||
// 计算最大代数
|
||||
const maxGeneration = Math.max(...members.map(m => m.generation || 0))
|
||||
// 计算最大代数(处理空数组的情况)
|
||||
const generations = members.map(m => m.generation || 0)
|
||||
const maxGeneration = generations.length > 0 ? Math.max(...generations) : 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
|
||||
const yearsSpan = birthYears.length > 0 ? new Date().getFullYear() - earliestYear : 0
|
||||
|
||||
return {
|
||||
totalMembers,
|
||||
@@ -106,8 +143,13 @@ 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">李氏家族族谱</h1>
|
||||
<p className="text-muted-foreground mt-1">陇西堂 · 第 24 代传人</p>
|
||||
<h1 className="text-3xl font-serif font-bold text-foreground">
|
||||
{currentTree?.name || '家族族谱'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{currentTree?.description || '记录家族历史,传承家族文化'}
|
||||
{stats.maxGeneration > 0 && ` · 第 ${stats.maxGeneration} 代传人`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/tree">
|
||||
|
||||
+124
-2
@@ -4,21 +4,82 @@ 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 } from "lucide-react"
|
||||
import { Download, Upload, AlertTriangle, RefreshCw, FileText, Bell, History, Database, User as UserIcon } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { useRef, useState } from "react"
|
||||
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"
|
||||
|
||||
interface UserProfile {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
ownedTreesCount: number
|
||||
collaborations: Array<{
|
||||
treeId: string
|
||||
treeName: string
|
||||
role: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { treeData, loadData, resetData } = useFamily()
|
||||
const { data: session } = useSession()
|
||||
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)
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col font-sans">
|
||||
@@ -27,6 +88,67 @@ export default function SettingsPage() {
|
||||
<h1 className="text-3xl font-serif font-bold mb-8">系统设置 (Settings)</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 用户信息和密码修改 - 两列布局 */}
|
||||
{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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChangePassword />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知设置和数据备份 - 两列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<NotificationSettings />
|
||||
|
||||
+232
-29
@@ -80,26 +80,115 @@ const chinaGeoJSON = {
|
||||
]
|
||||
}
|
||||
|
||||
// 城市坐标数据
|
||||
// 主要城市坐标(包含1、2、3线城市)
|
||||
const geoCoordMap: Record<string, [number, number]> = {
|
||||
'广州': [113.23, 23.16],
|
||||
'深圳': [114.07, 22.62],
|
||||
// 一线城市
|
||||
'北京': [116.46, 39.92],
|
||||
'上海': [121.48, 31.22],
|
||||
'广州': [113.23, 23.16],
|
||||
'深圳': [114.07, 22.62],
|
||||
|
||||
// 新一线城市
|
||||
'成都': [104.06, 30.67],
|
||||
'杭州': [120.19, 30.26],
|
||||
'南京': [118.78, 32.04],
|
||||
'武汉': [114.31, 30.52],
|
||||
'重庆': [106.55, 29.56],
|
||||
'西安': [108.95, 34.27],
|
||||
'厦门': [118.10, 24.46]
|
||||
'苏州': [120.62, 31.32],
|
||||
'武汉': [114.31, 30.52],
|
||||
'南京': [118.78, 32.04],
|
||||
'天津': [117.20, 39.13],
|
||||
'郑州': [113.65, 34.76],
|
||||
'长沙': [112.94, 28.23],
|
||||
'东莞': [113.75, 23.05],
|
||||
'沈阳': [123.43, 41.80],
|
||||
'青岛': [120.38, 36.07],
|
||||
'合肥': [117.27, 31.86],
|
||||
'佛山': [113.12, 23.02],
|
||||
|
||||
// 二线城市
|
||||
'昆明': [102.73, 25.04],
|
||||
'福州': [119.30, 26.08],
|
||||
'无锡': [120.29, 31.57],
|
||||
'厦门': [118.10, 24.46],
|
||||
'哈尔滨': [126.63, 45.75],
|
||||
'长春': [125.35, 43.88],
|
||||
'南昌': [115.89, 28.68],
|
||||
'济南': [117.12, 36.65],
|
||||
'宁波': [121.55, 29.87],
|
||||
'大连': [121.62, 38.91],
|
||||
'贵阳': [106.71, 26.57],
|
||||
'温州': [120.67, 28.00],
|
||||
'石家庄': [114.52, 38.05],
|
||||
'泉州': [118.58, 24.93],
|
||||
'南宁': [108.33, 22.84],
|
||||
'金华': [119.65, 29.08],
|
||||
'常州': [119.95, 31.79],
|
||||
'珠海': [113.52, 22.30],
|
||||
'惠州': [114.42, 23.09],
|
||||
'嘉兴': [120.76, 30.77],
|
||||
'南通': [120.86, 32.01],
|
||||
'中山': [113.38, 22.52],
|
||||
'保定': [115.48, 38.85],
|
||||
'太原': [112.55, 37.87],
|
||||
'兰州': [103.79, 36.06],
|
||||
'台州': [121.43, 28.66],
|
||||
|
||||
// 三线城市
|
||||
'乌鲁木齐': [87.68, 43.77],
|
||||
'绍兴': [120.58, 30.03],
|
||||
'廊坊': [116.70, 39.54],
|
||||
'洛阳': [112.45, 34.62],
|
||||
'威海': [122.12, 37.52],
|
||||
'盐城': [120.13, 33.38],
|
||||
'临沂': [118.35, 35.05],
|
||||
'江门': [113.08, 22.61],
|
||||
'汕头': [116.69, 23.39],
|
||||
'泰州': [119.90, 32.49],
|
||||
'漳州': [117.65, 24.52],
|
||||
'邯郸': [114.47, 36.60],
|
||||
'济宁': [116.59, 35.38],
|
||||
'芜湖': [118.38, 31.33],
|
||||
'淄博': [118.05, 36.78],
|
||||
'银川': [106.27, 38.47],
|
||||
'柳州': [109.40, 24.33],
|
||||
'绵阳': [104.73, 31.48],
|
||||
'湖州': [120.10, 30.86],
|
||||
'衡阳': [112.61, 26.89],
|
||||
'莆田': [119.01, 25.43],
|
||||
'宜昌': [111.29, 30.70],
|
||||
'桂林': [110.28, 25.29],
|
||||
'三亚': [109.51, 18.25],
|
||||
'遵义': [106.93, 27.71],
|
||||
'咸阳': [108.71, 34.33],
|
||||
'上饶': [117.97, 28.45],
|
||||
'莱芜': [117.68, 36.21],
|
||||
'赣州': [114.94, 25.85],
|
||||
'揭阳': [116.35, 23.55],
|
||||
'扬州': [119.42, 32.39],
|
||||
'菏泽': [115.47, 35.23],
|
||||
'徐州': [117.20, 34.26],
|
||||
'肇庆': [112.47, 23.05],
|
||||
'海口': [110.33, 20.02],
|
||||
'拉萨': [91.13, 29.66],
|
||||
'呼和浩特': [111.75, 40.84],
|
||||
'包头': [109.84, 40.66],
|
||||
'南阳': [112.53, 33.00],
|
||||
'鞍山': [122.99, 41.12],
|
||||
'九江': [115.99, 29.71],
|
||||
'大庆': [125.11, 46.59],
|
||||
'平顶山': [113.31, 33.74],
|
||||
'抚顺': [123.92, 41.88],
|
||||
'秦皇岛': [119.57, 39.95],
|
||||
'株洲': [113.15, 27.83],
|
||||
'齐齐哈尔': [123.95, 47.33]
|
||||
}
|
||||
|
||||
export default function TimelinePage() {
|
||||
const { treeData, getMember } = useFamily()
|
||||
const { treeData, getMember, isLoading } = useFamily()
|
||||
const members = Object.values(treeData.members)
|
||||
const [mapLoaded, setMapLoaded] = useState(false)
|
||||
|
||||
// 注册中国地图 - 从外部文件加载完整数据
|
||||
// 注册中国地图 - 从外部文件加载完整数据(必须在条件渲染之前)
|
||||
useEffect(() => {
|
||||
fetch('/china.json')
|
||||
.then(response => response.json())
|
||||
@@ -115,9 +204,9 @@ export default function TimelinePage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// -- Timeline Logic --
|
||||
// -- Timeline Logic -- (必须在条件渲染之前)
|
||||
// Flatten events: Births and Deaths
|
||||
const events = members
|
||||
const events = useMemo(() => members
|
||||
.flatMap((m) => {
|
||||
const evs = []
|
||||
if (m.birthDate) {
|
||||
@@ -138,19 +227,20 @@ export default function TimelinePage() {
|
||||
}
|
||||
return evs
|
||||
})
|
||||
.sort((a, b) => b.date.localeCompare(a.date)) // 倒序排列:最新的在上面
|
||||
.sort((a, b) => b.date.localeCompare(a.date)), [members]) // 倒序排列:最新的在上面
|
||||
|
||||
// -- Migration Logic --
|
||||
// Find moves between generations (Father -> Child location change)
|
||||
const migrations = members
|
||||
const migrations = useMemo(() => members
|
||||
.flatMap((m) => {
|
||||
if (!m.fatherId) return []
|
||||
const father = getMember(m.fatherId)
|
||||
if (!father) return []
|
||||
|
||||
// Check if location exists and is different
|
||||
const loc1 = father.ancestralHome || father.birthPlace
|
||||
const loc2 = m.ancestralHome || m.birthPlace
|
||||
// 优先使用 birthPlace(出生地)来判断迁徙,因为 ancestralHome(祖籍)通常不变
|
||||
const loc1 = father.birthPlace || father.ancestralHome
|
||||
const loc2 = m.birthPlace || m.ancestralHome
|
||||
|
||||
if (loc1 && loc2 && loc1 !== loc2) {
|
||||
return [
|
||||
@@ -165,27 +255,110 @@ export default function TimelinePage() {
|
||||
}
|
||||
return []
|
||||
})
|
||||
.sort((a, b) => (a.year || 0) - (b.year || 0))
|
||||
.sort((a, b) => (a.year || 0) - (b.year || 0)), [members, getMember])
|
||||
|
||||
// ECharts 地图配置
|
||||
const mapOption = useMemo(() => {
|
||||
// 城市坐标映射(简化城市名)
|
||||
const cityMap: Record<string, string> = {
|
||||
'广东省广州市': '广州',
|
||||
'广东省深圳市': '深圳',
|
||||
'北京市': '北京',
|
||||
'上海市': '上海',
|
||||
'四川省成都市': '成都',
|
||||
// 城市名称映射(支持省+市格式)
|
||||
const cityMap: Record<string, string> = {}
|
||||
|
||||
// 自动生成映射:将"XX省XX市"或"XX市"映射到城市名
|
||||
Object.keys(geoCoordMap).forEach(city => {
|
||||
cityMap[city] = city // 直接城市名
|
||||
cityMap[`${city}市`] = city // 城市+市
|
||||
|
||||
// 根据城市所在省份添加映射
|
||||
const provinceMap: Record<string, string[]> = {
|
||||
'广东省': ['广州', '深圳', '东莞', '佛山', '珠海', '惠州', '中山', '江门', '汕头', '肇庆'],
|
||||
'浙江省': ['杭州', '宁波', '温州', '金华', '嘉兴', '台州', '绍兴', '湖州'],
|
||||
'江苏省': ['苏州', '南京', '无锡', '常州', '南通', '盐城', '泰州', '扬州', '徐州'],
|
||||
'山东省': ['济南', '青岛', '威海', '临沂', '济宁', '淄博', '莱芜', '菏泽'],
|
||||
'四川省': ['成都', '绵阳'],
|
||||
'河北省': ['石家庄', '保定', '廊坊', '邯郸', '秦皇岛'],
|
||||
'福建省': ['福州', '厦门', '泉州', '莆田', '漳州'],
|
||||
'湖北省': ['武汉', '宜昌'],
|
||||
'湖南省': ['长沙', '衡阳', '株洲'],
|
||||
'陕西省': ['西安', '咸阳'],
|
||||
'河南省': ['郑州', '洛阳', '南阳', '平顶山'],
|
||||
'辽宁省': ['沈阳', '大连', '鞍山', '抚顺'],
|
||||
'安徽省': ['合肥', '芜湖'],
|
||||
'云南省': ['昆明'],
|
||||
'贵州省': ['贵阳', '遵义'],
|
||||
'广西壮族自治区': ['南宁', '柳州', '桂林'],
|
||||
'江西省': ['南昌', '九江', '赣州', '上饶'],
|
||||
'黑龙江省': ['哈尔滨', '大庆', '齐齐哈尔'],
|
||||
'吉林省': ['长春'],
|
||||
'山西省': ['太原'],
|
||||
'甘肃省': ['兰州'],
|
||||
'新疆维吾尔自治区': ['乌鲁木齐'],
|
||||
'宁夏回族自治区': ['银川'],
|
||||
'内蒙古自治区': ['呼和浩特', '包头'],
|
||||
'海南省': ['海口', '三亚'],
|
||||
'西藏自治区': ['拉萨'],
|
||||
'天津市': ['天津'],
|
||||
'重庆市': ['重庆'],
|
||||
'北京市': ['北京'],
|
||||
'上海市': ['上海']
|
||||
}
|
||||
|
||||
// 为每个城市添加"省+市"格式的映射
|
||||
Object.entries(provinceMap).forEach(([province, cities]) => {
|
||||
if (cities.includes(city)) {
|
||||
cityMap[`${province}${city}市`] = city
|
||||
cityMap[`${province}${city}`] = city
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 提取城市名称的辅助函数(去掉区的部分)
|
||||
const extractCity = (location: string): string => {
|
||||
// 尝试从 cityMap 中查找
|
||||
let city = cityMap[location]
|
||||
if (city && geoCoordMap[city]) return city
|
||||
|
||||
// 如果没找到,尝试提取省市部分(去掉区)
|
||||
// 例如:"山东省济南市历下区" -> "济南"
|
||||
const match = location.match(/(.+?省)?(.+?市)/)
|
||||
if (match) {
|
||||
const province = match[1] || ''
|
||||
const cityName = match[2] || ''
|
||||
|
||||
// 尝试多种组合
|
||||
const attempts = [
|
||||
location,
|
||||
province + cityName,
|
||||
cityName.replace('市', ''),
|
||||
cityName
|
||||
]
|
||||
|
||||
for (const attempt of attempts) {
|
||||
const mapped = cityMap[attempt]
|
||||
if (mapped && geoCoordMap[mapped]) return mapped
|
||||
}
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
// 转换迁徙数据为 ECharts 格式(使用经纬度坐标)
|
||||
const lines = migrations.map(mig => {
|
||||
const fromCity = cityMap[mig.from] || mig.from
|
||||
const toCity = cityMap[mig.to] || mig.to
|
||||
const fromCity = extractCity(mig.from)
|
||||
const toCity = extractCity(mig.to)
|
||||
const fromCoord = geoCoordMap[fromCity]
|
||||
const toCoord = geoCoordMap[toCity]
|
||||
|
||||
if (!fromCoord || !toCoord) return null
|
||||
if (!fromCoord || !toCoord) {
|
||||
console.log('坐标未找到:', {
|
||||
from: mig.from,
|
||||
to: mig.to,
|
||||
fromCity,
|
||||
toCity,
|
||||
fromCoord,
|
||||
toCoord
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
fromName: fromCity,
|
||||
@@ -194,11 +367,13 @@ export default function TimelinePage() {
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
console.log('迁徙数据:', { migrations, lines })
|
||||
|
||||
// 统计每个城市的迁入迁出
|
||||
const cityData: Record<string, number> = {}
|
||||
migrations.forEach(mig => {
|
||||
const from = cityMap[mig.from] || mig.from
|
||||
const to = cityMap[mig.to] || mig.to
|
||||
const from = extractCity(mig.from)
|
||||
const to = extractCity(mig.to)
|
||||
cityData[from] = (cityData[from] || 0) + 1
|
||||
cityData[to] = (cityData[to] || 0) + 1
|
||||
})
|
||||
@@ -251,10 +426,11 @@ export default function TimelinePage() {
|
||||
effect: {
|
||||
show: true,
|
||||
period: 3,
|
||||
trailLength: 0,
|
||||
trailLength: 0.2,
|
||||
symbol: 'arrow',
|
||||
symbolSize: 10,
|
||||
color: 'hsl(var(--primary))'
|
||||
symbolSize: 12,
|
||||
color: '#3b82f6',
|
||||
loop: true
|
||||
},
|
||||
zlevel: 1
|
||||
},
|
||||
@@ -292,6 +468,33 @@ export default function TimelinePage() {
|
||||
}
|
||||
}, [migrations])
|
||||
|
||||
// 显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<SiteHeader />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 显示无数据状态
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<SiteHeader />
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
暂无家族成员数据
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col font-sans">
|
||||
<SiteHeader />
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ export default function TreePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<SiteHeader />
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">No family tree data found.</div>
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">暂无家族树数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { ArrowLeft, TreePine } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function NewTreePage() {
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("请输入家族树名称")
|
||||
return
|
||||
}
|
||||
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/trees", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "创建失败")
|
||||
}
|
||||
|
||||
// 创建成功,强制刷新页面以更新家族树列表
|
||||
window.location.href = `/?treeId=${data.tree.id}`
|
||||
} catch (err: any) {
|
||||
setError(err.message || "创建失败,请稍后重试")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<SiteHeader />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle>需要登录</CardTitle>
|
||||
<CardDescription>请先登录后再创建家族树</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/auth/signin">前往登录</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mb-4"
|
||||
>
|
||||
<Link href="/">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-serif font-bold mb-2">创建新家族树</h1>
|
||||
<p className="text-muted-foreground">
|
||||
开始记录您的家族历史和传承
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<TreePine className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>家族树信息</CardTitle>
|
||||
<CardDescription>
|
||||
填写基本信息以创建您的家族树
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
家族树名称 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="例如:王氏家族、李氏宗谱"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
建议使用姓氏+家族/宗谱等命名
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">家族树描述(可选)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="简要描述您的家族历史、祖籍地等信息"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{description.length}/500 字
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<h3 className="font-medium mb-2">💡 温馨提示</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• 创建后可以随时修改家族树信息</li>
|
||||
<li>• 您可以邀请家族成员共同编辑</li>
|
||||
<li>• 支持导入已有的家族数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !name.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? "创建中..." : "创建家族树"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user