This commit is contained in:
freedakgmail
2025-12-21 17:32:33 +08:00
parent cffdd75e96
commit 5c8c70097c
190 changed files with 2746 additions and 893 deletions
+3 -1
View File
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { useDialog } from "@/components/ui/alert-dialog-custom"
interface Collaborator {
id: string
@@ -82,6 +83,7 @@ interface LoginLog {
export default function AdminPage() {
const { data: session, status } = useSession()
const router = useRouter()
const { showAlert } = useDialog()
const [users, setUsers] = useState<UserInfo[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
@@ -176,7 +178,7 @@ export default function AdminPage() {
)
)
} catch (err) {
alert(err instanceof Error ? err.message : "操作失败")
await showAlert(err instanceof Error ? err.message : "操作失败", "错误")
} finally {
setUpdating(null)
}
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma"
import { checkPermission } from "@/lib/permissions"
import { Role } from "@prisma/client"
import { recordMemberHistory } from "@/lib/member-history"
import { recalculateGenerations, isRootMember, hasChildren, findNewRootMember } from "@/lib/generation-utils"
// 获取单个成员
export async function GET(
@@ -323,10 +324,183 @@ export async function DELETE(
return NextResponse.json({ error: "成员不存在" }, { status: 404 })
}
// 获取家族树信息
const tree = await prisma.familyTree.findUnique({
where: { id: treeId },
select: { rootMemberId: true }
})
// 检查是否删除的是根成员(始祖)
const isDeletingRoot = await isRootMember(memberId)
const memberHasChildren = await hasChildren(memberId)
let needRecalculate = false
let newRootId: string | null = null
// 如果删除的是根成员且有子女,需要调整全族代数
if (isDeletingRoot && memberHasChildren) {
needRecalculate = true
}
// 清理关系引用
// 1. 清理配偶关系
if (existingMember.spouseIds && existingMember.spouseIds.length > 0) {
for (const spouseId of existingMember.spouseIds) {
const spouse = await prisma.familyMember.findUnique({
where: { id: spouseId }
})
if (spouse && spouse.spouseIds.includes(memberId)) {
await prisma.familyMember.update({
where: { id: spouseId },
data: {
spouseIds: spouse.spouseIds.filter(id => id !== memberId)
}
})
}
}
}
// 2. 清理父母的子女引用
if (existingMember.fatherId) {
const father = await prisma.familyMember.findUnique({
where: { id: existingMember.fatherId }
})
if (father && father.childrenIds.includes(memberId)) {
await prisma.familyMember.update({
where: { id: existingMember.fatherId },
data: {
childrenIds: father.childrenIds.filter(id => id !== memberId)
}
})
}
}
if (existingMember.motherId) {
const mother = await prisma.familyMember.findUnique({
where: { id: existingMember.motherId }
})
if (mother && mother.childrenIds.includes(memberId)) {
await prisma.familyMember.update({
where: { id: existingMember.motherId },
data: {
childrenIds: mother.childrenIds.filter(id => id !== memberId)
}
})
}
}
// 3. 清理子女的父母引用
if (existingMember.childrenIds && existingMember.childrenIds.length > 0) {
for (const childId of existingMember.childrenIds) {
const child = await prisma.familyMember.findUnique({
where: { id: childId }
})
if (child) {
const updates: any = {}
if (child.fatherId === memberId) {
updates.fatherId = null
}
if (child.motherId === memberId) {
updates.motherId = null
}
if (Object.keys(updates).length > 0) {
await prisma.familyMember.update({
where: { id: childId },
data: updates
})
}
}
}
}
// 删除成员
await prisma.familyMember.delete({
where: { id: memberId }
})
// 如果需要调整全族代数
if (needRecalculate) {
try {
// 将所有成员的代数 -1
await recalculateGenerations(treeId, -1)
// 查找新的根成员
newRootId = await findNewRootMember(treeId)
if (newRootId) {
// 更新家族树的 rootMemberId
await prisma.familyTree.update({
where: { id: treeId },
data: { rootMemberId: newRootId }
})
// 将新根成员设为始祖
await prisma.familyMember.update({
where: { id: newRootId },
data: { isFounder: true }
})
} else {
// 如果没有找到新的根成员,清空 rootMemberId
await prisma.familyTree.update({
where: { id: treeId },
data: { rootMemberId: null }
})
}
// 记录全族代数调整的日志
await prisma.activityLog.create({
data: {
treeId,
userId: session.user.id,
action: 'UPDATE',
entityType: 'TREE',
entityId: treeId,
entityName: `全族代数调整(-1代)`,
changes: {
reason: '删除始祖',
offset: -1,
newRootMemberId: newRootId,
deletedMemberId: memberId,
deletedMemberName: existingMember.fullName
}
}
})
} catch (error) {
console.error('调整全族代数失败:', error)
// 注意:此时成员已被删除,无法回滚
// 记录错误日志
await prisma.activityLog.create({
data: {
treeId,
userId: session.user.id,
action: 'ERROR',
entityType: 'TREE',
entityId: treeId,
entityName: '代数调整失败',
changes: {
error: '删除始祖后调整代数失败',
deletedMemberId: memberId
}
}
})
}
} else if (tree?.rootMemberId === memberId) {
// 如果删除的是根成员但没有子女,需要更新 rootMemberId
// 情况1: 有配偶,配偶成为新根
if (existingMember.spouseIds && existingMember.spouseIds.length > 0) {
const newRoot = existingMember.spouseIds[0]
await prisma.familyTree.update({
where: { id: treeId },
data: { rootMemberId: newRoot }
})
} else {
// 情况2: 无配偶无子女,清空 rootMemberId
await prisma.familyTree.update({
where: { id: treeId },
data: { rootMemberId: null }
})
}
}
// 记录操作日志
await prisma.activityLog.create({
data: {
@@ -339,7 +513,11 @@ export async function DELETE(
}
})
return NextResponse.json({ success: true })
return NextResponse.json({
success: true,
generationRecalculated: needRecalculate,
newRootMemberId: newRootId
})
} catch (error) {
console.error("删除成员失败:", error)
return NextResponse.json(
+111 -1
View File
@@ -6,6 +6,7 @@ import { checkPermission } from "@/lib/permissions"
import { Role, Gender } from "@prisma/client"
import { z } from "zod"
import { recordMemberHistory } from "@/lib/member-history"
import { recalculateGenerations, getMinGeneration, findNewRootMember } from "@/lib/generation-utils"
const createMemberSchema = z.object({
surname: z.string().min(1),
@@ -122,6 +123,35 @@ export async function POST(
const body = await req.json()
const data = createMemberSchema.parse(body)
// 检查是否需要调整全族代数
const minGeneration = await getMinGeneration(treeId)
let needRecalculate = false
let generationOffset = 0
// 检查是否给第1代成员添加父母
let isAddingParentToFounder = false
if (data.childrenIds && data.childrenIds.length > 0) {
// 检查子女中是否有第1代成员
const children = await prisma.familyMember.findMany({
where: {
id: { in: data.childrenIds },
generation: 1
},
select: { id: true }
})
isAddingParentToFounder = children.length > 0
}
if (minGeneration !== null && data.generation < minGeneration) {
// 新成员的代数小于当前最小代数,需要调整全族代数
needRecalculate = true
generationOffset = minGeneration - data.generation
} else if (isAddingParentToFounder && minGeneration === 1) {
// 给第1代成员添加父母,需要将全族代数+1
needRecalculate = true
generationOffset = 1
}
const member = await prisma.familyMember.create({
data: {
...data,
@@ -204,6 +234,82 @@ export async function POST(
}
}
// 如果需要调整全族代数
if (needRecalculate) {
try {
if (isAddingParentToFounder) {
// 给第1代成员添加父母的情况
// 1. 先将所有现有成员的代数+1(包括原来的第1代)
await recalculateGenerations(treeId, generationOffset, member.id)
// 2. 将原始祖的 isFounder 设为 false
const oldFounders = await prisma.familyMember.findMany({
where: {
treeId,
isFounder: true,
id: { not: member.id }
}
})
for (const oldFounder of oldFounders) {
await prisma.familyMember.update({
where: { id: oldFounder.id },
data: { isFounder: false }
})
}
// 3. 新父母成员保持代数为1,并设为始祖
await prisma.familyMember.update({
where: { id: member.id },
data: { generation: 1, isFounder: true }
})
// 4. 更新家族树的 rootMemberId 为新父母
await prisma.familyTree.update({
where: { id: treeId },
data: { rootMemberId: member.id }
})
} else {
// 其他情况:新成员代数小于最小代数
// 调整其他成员的代数(排除新创建的成员)
await recalculateGenerations(treeId, generationOffset, member.id)
// 将新成员的代数设为1(成为新的始祖)
await prisma.familyMember.update({
where: { id: member.id },
data: { generation: 1 }
})
// 更新家族树的 rootMemberId
await prisma.familyTree.update({
where: { id: treeId },
data: { rootMemberId: member.id }
})
}
// 记录全族代数调整的日志
await prisma.activityLog.create({
data: {
treeId,
userId: session.user.id,
action: 'UPDATE',
entityType: 'TREE',
entityId: treeId,
entityName: `全族代数调整(+${generationOffset}代)`,
changes: {
reason: isAddingParentToFounder ? '给始祖添加父母' : '添加更早代数成员',
offset: generationOffset,
newRootMemberId: member.id
}
}
})
} catch (error) {
console.error('调整全族代数失败:', error)
// 如果调整失败,删除新创建的成员
await prisma.familyMember.delete({ where: { id: member.id } })
throw new Error('调整全族代数失败,操作已回滚')
}
}
// 记录成员历史版本
await recordMemberHistory({
memberId: member.id,
@@ -225,7 +331,11 @@ export async function POST(
}
})
return NextResponse.json({ member }, { status: 201 })
return NextResponse.json({
member,
generationRecalculated: needRecalculate,
generationOffset: needRecalculate ? generationOffset : 0
}, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
+8 -7
View File
@@ -1,5 +1,5 @@
import type React from "react"
import type { Metadata } from "next"
import type { Metadata, Viewport } from "next"
import { Geist, Geist_Mono } from "next/font/google"
// import { Analytics } from "@vercel/analytics/next"
import "./globals.css"
@@ -16,12 +16,6 @@ export const metadata: Metadata = {
description: "现代化的中国家族树管理系统,支持多用户协作、权限管理、数据导入导出等功能",
generator: "v0.app",
manifest: "/manifest.json",
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
@@ -38,6 +32,13 @@ export const metadata: Metadata = {
},
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default function RootLayout({
children,
}: Readonly<{
+114 -89
View File
@@ -6,6 +6,7 @@ import type { FamilyMember } from "@/types/family"
import { PhotoGallery } from "@/components/members/photo-gallery"
import { StoryManager } from "@/components/members/story-manager"
import { useDialog } from "@/components/ui/alert-dialog-custom"
// useDialog 已导入,用于 showAlert 和 showConfirm
import { permissions } from "@/lib/permissions"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { MemberVersionHistory } from "@/components/members/member-version-history"
@@ -24,7 +25,7 @@ import { AddRelationDialog, type RelationType } from "@/components/tree/add-rela
export default function MemberProfilePage() {
const { id } = useParams()
const { treeData, currentTree, getMember, updateMember, deleteMember, isLoading } = useFamily()
const { showConfirm } = useDialog()
const { showConfirm, showAlert } = useDialog()
const router = useRouter()
const { data: session, status } = useSession()
const [isEditing, setIsEditing] = useState(false)
@@ -90,6 +91,90 @@ export default function MemberProfilePage() {
}
}, [member?.avatarUrl])
// 姓名分解函数
const parseName = (fullName: string): { surname: string; givenName: string } => {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
const compoundSurnames = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
for (const compound of compoundSurnames) {
if (name.startsWith(compound) && name.length > compound.length) {
return { surname: compound, givenName: name.slice(compound.length) }
}
}
if (name.length >= 2) {
return { surname: name.charAt(0), givenName: name.slice(1) }
}
return { surname: name, givenName: '' }
}
// 创建新成员的处理函数 - 必须在条件return之前定义
const handleCreateNewRelation = useCallback((prefillName: string, relationType: RelationType) => {
if (!member) return
const params = new URLSearchParams()
const { surname, givenName } = parseName(prefillName)
if (surname) params.set("surname", surname)
if (givenName) params.set("givenName", givenName)
if (relationType === "child") {
params.set("generation", String(member.generation + 1))
if (member.gender === "MALE") {
params.set("fatherId", member.id)
if (member.spouseIds?.length) {
params.set("motherId", member.spouseIds[0])
}
} else if (member.gender === "FEMALE") {
params.set("motherId", member.id)
if (member.spouseIds?.length) {
params.set("fatherId", member.spouseIds[0])
}
}
} else if (relationType === "spouse") {
params.set("generation", String(member.generation))
params.set("spouseId", member.id)
params.set("gender", member.gender === "MALE" ? "FEMALE" : "MALE")
if (member.childrenIds && member.childrenIds.length > 0) {
member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
} else if (relationType === "father") {
params.set("generation", String(member.generation === 1 ? 1 : member.generation - 1))
params.set("childId", member.id)
params.set("gender", "MALE")
if (member.motherId) {
params.set("spouseId", member.motherId)
}
} else if (relationType === "mother") {
params.set("generation", String(member.generation === 1 ? 1 : member.generation - 1))
params.set("childId", member.id)
params.set("gender", "FEMALE")
if (member.fatherId) {
params.set("spouseId", member.fatherId)
}
}
if (currentTree?.id) {
params.set("treeId", currentTree.id)
}
setRelationDialog(null)
router.push(`/members/new?${params.toString()}`)
}, [member, currentTree?.id, router])
// 显示加载状态
if (isLoading) {
return (
@@ -122,97 +207,37 @@ export default function MemberProfilePage() {
}
const handleDelete = async () => {
const confirmed = await showConfirm("确定要删除这位成员吗?这将同时删除其关联关系。", "删除成员", "destructive")
// 检查是否正在删除始祖
let confirmMessage = "确定要删除这位成员吗?这将同时删除其关联关系。"
if (member.generation === 1 && (!member.fatherId && !member.motherId)) {
confirmMessage = "您正在删除始祖,这将导致全族代数重新计算。\n原第2代将变为第1代,以此类推。\n\n确定要删除这位成员吗?"
}
const confirmed = await showConfirm(confirmMessage, "删除成员", "destructive")
if (confirmed) {
deleteMember(member.id)
const params = new URLSearchParams(window.location.search)
router.push(`/members?${params.toString()}`)
try {
const result = await deleteMember(member.id)
// 构建跳转参数,只保留 treeId
const treeId = new URLSearchParams(window.location.search).get('treeId')
const params = new URLSearchParams()
if (treeId) params.set('treeId', treeId)
// 如果有提示信息(代数调整),显示给用户,等待确认后再跳转
if (result.message) {
await showAlert(result.message, "提示")
}
// 删除后跳转到成员列表页
router.push(`/members?${params.toString()}`)
} catch (error) {
console.error('删除成员失败:', error)
await showAlert('删除成员失败: ' + (error as Error).message, "错误")
}
}
}
// 姓名分解函数
const parseName = (fullName: string): { surname: string; givenName: string } => {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
const compoundSurnames = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
for (const compound of compoundSurnames) {
if (name.startsWith(compound) && name.length > compound.length) {
return { surname: compound, givenName: name.slice(compound.length) }
}
}
if (name.length >= 2) {
return { surname: name.charAt(0), givenName: name.slice(1) }
}
return { surname: name, givenName: '' }
}
// 创建新成员的处理函数
const handleCreateNewRelation = useCallback((prefillName: string, relationType: RelationType) => {
if (!member) return
const params = new URLSearchParams()
const { surname, givenName } = parseName(prefillName)
if (surname) params.set("surname", surname)
if (givenName) params.set("givenName", givenName)
if (relationType === "child") {
params.set("generation", String(member.generation + 1))
if (member.gender === "MALE") {
params.set("fatherId", member.id)
if (member.spouseIds?.length) {
params.set("motherId", member.spouseIds[0])
}
} else if (member.gender === "FEMALE") {
params.set("motherId", member.id)
if (member.spouseIds?.length) {
params.set("fatherId", member.spouseIds[0])
}
}
} else if (relationType === "spouse") {
params.set("generation", String(member.generation))
params.set("spouseId", member.id)
params.set("gender", member.gender === "MALE" ? "FEMALE" : "MALE")
if (member.childrenIds && member.childrenIds.length > 0) {
member.childrenIds.forEach(id => {
params.append('childrenIds', id)
})
}
} else if (relationType === "father") {
params.set("generation", String(member.generation - 1))
params.set("childId", member.id)
params.set("gender", "MALE")
if (member.motherId) {
params.set("spouseId", member.motherId)
}
} else if (relationType === "mother") {
params.set("generation", String(member.generation - 1))
params.set("childId", member.id)
params.set("gender", "FEMALE")
if (member.fatherId) {
params.set("spouseId", member.fatherId)
}
}
if (currentTree?.id) {
params.set("treeId", currentTree.id)
}
setRelationDialog(null)
router.push(`/members/new?${params.toString()}`)
}, [member, currentTree?.id, router])
// 创建新成员的处理函数 - 已移到条件return之前
const handleQuickAddChild = () => {
setRelationDialog({ type: 'child' })
@@ -259,7 +284,7 @@ export default function MemberProfilePage() {
setLastSaveTime(Date.now())
} catch (error) {
console.error('上传头像失败:', error)
alert('上传头像失败: ' + (error instanceof Error ? error.message : '未知错误'))
showAlert('上传头像失败: ' + (error instanceof Error ? error.message : '未知错误'), "错误")
} finally {
setIsUploadingAvatar(false)
}
+62 -13
View File
@@ -12,23 +12,25 @@ import type { FamilyMember } from "@/types/family"
import { useMemo, useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import { BackButton } from "@/components/ui/back-button"
import { useDialog } from "@/components/ui/alert-dialog-custom"
export default function NewMemberPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { addMember, treeData, getMember } = useFamily()
const { showAlert, showConfirm } = useDialog()
const { data: session, status } = useSession()
const [activeTab, setActiveTab] = useState("info")
const existingMembers = Object.values(treeData.members)
const isFirstMember = existingMembers.length === 0
// 未登录时重定向到登录页
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/auth/signin')
}
}, [status, router])
const existingMembers = Object.values(treeData.members)
const isFirstMember = existingMembers.length === 0
// 从URL参数获取预填信息
const initialData = useMemo(() => {
@@ -128,9 +130,46 @@ export default function NewMemberPage() {
return '成员'
}, [searchParams, isFirstMember])
// 判断是否是给始祖添加父母(新成员将成为新始祖)
const isAddingParentToFounder = useMemo(() => {
const childId = searchParams.get('childId')
if (!childId) return false
const child = getMember(childId)
return child?.generation === 1
}, [searchParams, getMember])
// 是否应该显示始祖样式
const showFounderStyle = isFirstMember || isAddingParentToFounder
const handleSave = async (newMember: FamilyMember) => {
try {
// 检查是否正在给始祖添加父母
const childId = searchParams.get('childId')
if (childId && memberType === '父母') {
const child = getMember(childId)
// 如果子女不存在(已被删除),提示用户
if (!child) {
await showAlert('目标成员已不存在,无法添加父母。请返回成员列表重新操作。', "提示")
const params = new URLSearchParams(window.location.search)
router.push(`/members?${params.toString()}`)
return
}
// 如果子女是第1代(始祖),需要确认
if (child.generation === 1) {
const confirmed = await showConfirm(
'您正在给始祖添加父母,这将导致全族代数重新计算。\n原第1代将变为第2代,以此类推。\n\n是否继续?',
"确认操作"
)
if (!confirmed) {
return
}
}
}
// 如果是第一个成员,自动设置为始祖
const memberData = isFirstMember ? {
...newMember,
@@ -144,13 +183,23 @@ export default function NewMemberPage() {
updatedAt: new Date().toISOString(),
}
await addMember(memberData)
// 跳转到成员列表页,保留treeId参数
const params = new URLSearchParams(window.location.search)
router.push(`/members?${params.toString()}`)
const result = await addMember(memberData)
// 构建跳转参数
const params = new URLSearchParams()
const treeId = searchParams.get('treeId')
if (treeId) params.set('treeId', treeId)
// 如果有提示信息(代数调整),显示给用户,等待用户确认后再跳转
if (result.message) {
await showAlert(result.message, "提示")
}
// 跳转到新成员的详情页
router.push(`/members/${result.member.id}?${params.toString()}`)
} catch (error) {
console.error('添加成员失败:', error)
alert('添加成员失败: ' + (error as Error).message)
await showAlert('添加成员失败: ' + (error as Error).message, "错误")
}
}
@@ -167,8 +216,8 @@ export default function NewMemberPage() {
{/* Header Section */}
<div className="flex flex-col md:flex-row justify-between items-start gap-3 md:gap-6 pb-3 md:pb-4">
<div className="flex items-start gap-3 md:gap-6">
<div className={`h-14 w-14 md:h-24 md:w-24 rounded-lg bg-muted flex items-center justify-center border-2 ${isFirstMember ? 'border-amber-400 bg-amber-50/30' : 'border-primary/20'} overflow-hidden flex-shrink-0`}>
{isFirstMember ? (
<div className={`h-14 w-14 md:h-24 md:w-24 rounded-lg bg-muted flex items-center justify-center border-2 ${showFounderStyle ? 'border-amber-400 bg-amber-50/30' : 'border-primary/20'} overflow-hidden flex-shrink-0`}>
{showFounderStyle ? (
<span className="text-2xl md:text-4xl">👑</span>
) : (
<UserPlus className="h-8 w-8 md:h-16 md:w-16 text-muted-foreground" />
@@ -177,10 +226,10 @@ export default function NewMemberPage() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 md:gap-3 mb-1 md:mb-2 flex-wrap">
<h1 className="text-lg md:text-3xl font-bold font-serif text-foreground flex items-center gap-1 md:gap-2">
{isFirstMember && <span className="text-amber-600 text-lg md:text-2xl">👑</span>}
{isFirstMember ? '添加家族始祖' : `添加${memberType}`}
{showFounderStyle && <span className="text-amber-600 text-lg md:text-2xl">👑</span>}
{isFirstMember ? '添加家族始祖' : isAddingParentToFounder ? '添加新始祖' : `添加${memberType}`}
</h1>
{isFirstMember && (
{showFounderStyle && (
<span className="px-2 md:px-3 py-0.5 md:py-1 bg-gradient-to-r from-amber-100 to-orange-100 text-amber-800 text-xs md:text-sm font-semibold rounded-full border-2 border-amber-300 shadow-sm">
· 1
</span>
+118 -77
View File
@@ -29,6 +29,8 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { GenerationWarningDialog } from "@/components/tree/generation-warning-dialog"
import { isFirstGenerationMember, shouldShowGenerationWarning } from "@/lib/generation-utils"
// 占位符类型
interface PlaceholderMember {
@@ -81,8 +83,14 @@ const MemberCard = memo(function MemberCard({
canEdit: boolean
onOpenAddDialog: (member: FamilyMember, type: RelationType) => void
}) {
// 代数重新计算警告对话框状态
const [showWarningDialog, setShowWarningDialog] = useState(false)
const [pendingRelationType, setPendingRelationType] = useState<'father' | 'mother' | null>(null)
// 判断是否是始祖(使用 rootMemberId 而不是 isFounder 字段)
const isFounder = member.id === currentTreeId
// 判断是否是配偶
const isFounder = member.isFounder === true
const isSpouse = !member.fatherId && !member.motherId &&
member.spouseIds && member.spouseIds.length > 0 &&
!isFounder
@@ -132,7 +140,8 @@ const MemberCard = memo(function MemberCard({
case 'father':
params.set('childId', member.id)
params.set('gender', 'MALE')
params.set('generation', (member.generation - 1).toString())
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
// 如果已有母亲,设为配偶
if (member.motherId) {
params.set('spouseId', member.motherId)
@@ -141,7 +150,8 @@ const MemberCard = memo(function MemberCard({
case 'mother':
params.set('childId', member.id)
params.set('gender', 'FEMALE')
params.set('generation', (member.generation - 1).toString())
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set('generation', member.generation === 1 ? '1' : (member.generation - 1).toString())
// 如果已有父亲,设为配偶
if (member.fatherId) {
params.set('spouseId', member.fatherId)
@@ -183,14 +193,28 @@ const MemberCard = memo(function MemberCard({
}
const handleAddMember = (type: RelationType) => {
// 检查是否需要显示代数重新计算警告
if ((type === 'father' || type === 'mother') && shouldShowGenerationWarning(member, type)) {
setPendingRelationType(type)
setShowWarningDialog(true)
return
}
onOpenAddDialog(member, type)
}
// 确认添加父母后的处理
const handleConfirmAddParent = () => {
if (pendingRelationType) {
onOpenAddDialog(member, pendingRelationType)
setPendingRelationType(null)
}
}
// 卡片内容
const cardContent = (
<div className="relative group">
<Link href={`/members/${member.id}${typeof window !== 'undefined' ? window.location.search : ''}`}>
<ChineseCard variant="default" className={`hover:shadow-lg transition-all cursor-pointer h-full ${cardStyle}`}>
<ChineseCard variant="default" className={`hover:shadow-lg transition-all cursor-pointer h-full rounded-xl ${cardStyle}`}>
<ChineseCardContent className="px-4 py-3 flex items-center gap-5">
<div className="relative flex-shrink-0">
<AvatarDisplay
@@ -345,73 +369,86 @@ const MemberCard = memo(function MemberCard({
// 有编辑权限时,包裹右键菜单
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{cardContent}
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuLabel className="text-xs text-muted-foreground">
{member.fullName}
</ContextMenuLabel>
<ContextMenuSeparator />
{canAddFather && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('father')
}}
>
<UserPlus className="h-4 w-4 text-blue-500" />
</ContextMenuItem>
)}
{canAddMother && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('mother')
}}
>
<UserPlus className="h-4 w-4 text-pink-500" />
</ContextMenuItem>
)}
{(canAddFather || canAddMother) && (canAddSpouse || canAddChild) && (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
{cardContent}
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuLabel className="text-xs text-muted-foreground">
{member.fullName}
</ContextMenuLabel>
<ContextMenuSeparator />
)}
{canAddSpouse && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('spouse')
}}
>
<Users className="h-4 w-4 text-red-500" />
</ContextMenuItem>
)}
{canAddChild && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('child')
}}
>
<Baby className="h-4 w-4 text-green-500" />
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
{canAddFather && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('father')
}}
>
<UserPlus className="h-4 w-4 text-blue-500" />
</ContextMenuItem>
)}
{canAddMother && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('mother')
}}
>
<UserPlus className="h-4 w-4 text-pink-500" />
</ContextMenuItem>
)}
{(canAddFather || canAddMother) && (canAddSpouse || canAddChild) && (
<ContextMenuSeparator />
)}
{canAddSpouse && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('spouse')
}}
>
<Users className="h-4 w-4 text-red-500" />
</ContextMenuItem>
)}
{canAddChild && (
<ContextMenuItem
className="gap-2 cursor-pointer"
onPointerDown={(e) => {
e.preventDefault()
handleAddMember('child')
}}
>
<Baby className="h-4 w-4 text-green-500" />
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
{/* 代数重新计算警告对话框 */}
{pendingRelationType && (
<GenerationWarningDialog
open={showWarningDialog}
onOpenChange={setShowWarningDialog}
memberName={member.fullName}
relationType={pendingRelationType}
onConfirm={handleConfirmAddParent}
/>
)}
</>
)
})
@@ -495,14 +532,16 @@ export default function MembersPage() {
})
}
} else if (relationType === "father") {
params.set("generation", String(relationDialog.member.generation - 1))
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "MALE")
if (relationDialog.member.motherId) {
params.set("spouseId", relationDialog.member.motherId)
}
} else if (relationType === "mother") {
params.set("generation", String(relationDialog.member.generation - 1))
// 如果是第1代成员,新父母的世代也设为1(后端会重新计算所有成员世代)
params.set("generation", String(relationDialog.member.generation === 1 ? 1 : relationDialog.member.generation - 1))
params.set("childId", relationDialog.member.id)
params.set("gender", "FEMALE")
if (relationDialog.member.fatherId) {
@@ -642,7 +681,8 @@ export default function MembersPage() {
// 配偶定义:没有父母但有配偶的成员(嫁入/娶入的成员)
// 其他所有成员都应该显示,包括孤立成员
const familyMembers = members.filter(m => {
const isFounder = m.isFounder === true
// 使用 treeData.rootId 判断始祖,而不是 isFounder 字段
const isFounder = m.id === treeData.rootId
const hasFamilyParents = m.fatherId || m.motherId
const hasSpouse = m.spouseIds && m.spouseIds.length > 0
@@ -661,8 +701,9 @@ export default function MembersPage() {
// 对家族成员按年龄排序
const sortedFamilyMembers = familyMembers.sort((a, b) => {
const aIsFounder = a.isFounder === true
const bIsFounder = b.isFounder === true
// 使用 treeData.rootId 判断始祖
const aIsFounder = a.id === treeData.rootId
const bIsFounder = b.id === treeData.rootId
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
@@ -689,7 +730,7 @@ export default function MembersPage() {
actualCount: actualMemberCount
}
})
}, [filteredMembers])
}, [filteredMembers, treeData.rootId])
const clearFilters = () => {
setSelectedSurname("all")
@@ -866,7 +907,7 @@ export default function MembersPage() {
const params = new URLSearchParams(window.location.search)
router.push(`/tree?${params.toString()}`)
}}
currentTreeId={currentTree?.id}
currentTreeId={treeData.rootId || undefined}
canEdit={permissions.canCreate(currentTree?.currentUserRole)}
onOpenAddDialog={handleOpenAddDialog}
/>
+110 -4
View File
@@ -7,11 +7,11 @@ import dynamic from "next/dynamic"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle, ChineseCardDescription, ChineseDivider } from "@/components/ui/chinese-card"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogTitle, DialogHeader, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { SiteHeader } from "@/components/site-header"
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, Calculator, Loader2, Images, ChevronDown, ChevronUp, EyeOff, Play, Video } from "lucide-react"
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen, BarChart3, Calculator, Loader2, Images, ChevronDown, ChevronUp, EyeOff, Play, Video, Edit } from "lucide-react"
import { useFamily } from "@/context/family-context"
import { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
@@ -24,7 +24,11 @@ import { isInCurrentMonth, solar2lunar, lunar2solar } from "@/lib/lunar-calendar
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import type { FamilyPhoto } from "@/types/family"
import { useDialog } from "@/components/ui/alert-dialog-custom"
// 动态导入统计图表组件(减少初始加载体积)
const StatisticsCharts = dynamic(
@@ -63,10 +67,14 @@ const isVideoFile = (url: string) => {
export default function DashboardPage() {
const { treeData, isLoading, currentTree, updateMember } = useFamily()
const { data: session } = useSession()
const { showAlert } = useDialog()
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
const [statsExpanded, setStatsExpanded] = useState(true)
const [adminToggleLoading, setAdminToggleLoading] = useState<string | null>(null)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editForm, setEditForm] = useState({ name: '', description: '' })
const [isSaving, setIsSaving] = useState(false)
const router = useRouter()
// 首次登录跳转到使用帮助
@@ -223,6 +231,38 @@ export default function DashboardPage() {
}
}, [isOwner, treeData.members, updateMember])
// 保存编辑
const handleSaveEdit = async () => {
if (!currentTree?.id || !editForm.name.trim()) return
setIsSaving(true)
try {
const response = await fetch(`/api/trees/${currentTree.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: editForm.name.trim(),
description: editForm.description.trim() || null,
}),
})
if (!response.ok) {
throw new Error('更新失败')
}
setEditDialogOpen(false)
// 刷新页面以显示更新后的数据
window.location.reload()
} catch (error) {
console.error('更新家族信息失败:', error)
await showAlert('更新失败,请重试', "错误")
} finally {
setIsSaving(false)
}
}
// 获取本月纪念日(生日和忌日)- 支持农历
const monthlyAnniversaries = useMemo(() => {
const members = Object.values(treeData.members)
@@ -452,12 +492,28 @@ export default function DashboardPage() {
</div>
<p className="text-muted-foreground mt-1">
{currentTree?.description || '记录家族历史,传承家族文化'}
{stats.maxGeneration > 0 && ` · ${stats.maxGeneration} 代传人`}
{stats.maxGeneration > 0 && ` · ${stats.maxGeneration} 代传人`}
</p>
</div>
<div className="flex items-center gap-2">
{session && currentTree?.ownerId === session.user?.id && (
<CollaboratorDialog />
<>
<CollaboratorDialog />
<Button
variant="outline"
className="gap-2"
onClick={() => {
setEditForm({
name: currentTree?.name || '',
description: currentTree?.description || ''
})
setEditDialogOpen(true)
}}
>
<Edit className="h-4 w-4" />
</Button>
</>
)}
{permissions.canCreate(currentTree?.currentUserRole) && (
<Link href={currentTree?.id ? `/members/new?treeId=${currentTree.id}` : '/members/new'}>
@@ -1181,6 +1237,56 @@ export default function DashboardPage() {
)}
</DialogContent>
</Dialog>
{/* 编辑家族信息对话框 */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="tree-name"></Label>
<Input
id="tree-name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder="请输入家族名称"
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tree-description"></Label>
<Textarea
id="tree-description"
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="请输入家族说明"
rows={4}
maxLength={200}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditDialogOpen(false)}
disabled={isSaving}
>
</Button>
<Button
onClick={handleSaveEdit}
disabled={isSaving || !editForm.name.trim()}
>
{isSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</div>
)
+5 -3
View File
@@ -24,6 +24,7 @@ import { RelationshipPathDisplay } from "@/components/relationship-path-display"
import { useSession } from "next-auth/react"
import { EmptyStateBadge } from "@/components/empty-state-badge"
import { exportFamilyHTML } from "@/lib/export-pdf"
import { useDialog } from "@/components/ui/alert-dialog-custom"
// 动态导入 D3 图表组件(减少初始加载体积)
const D3OrgChartFlow = dynamic(
@@ -48,6 +49,7 @@ export default function TreePage() {
const { treeData, currentTree, refreshData } = useFamily()
const { calculateRelationship } = useRelationship()
const { data: session, status } = useSession()
const { showAlert } = useDialog()
const router = useRouter()
const searchParams = useSearchParams()
const [viewMode, setViewMode] = useState<ViewMode>("traditional")
@@ -641,7 +643,7 @@ export default function TreePage() {
// 导出 PDF(按世代导出成员信息)
const handleExportPDF = useCallback(async () => {
if (!treeData.members || Object.keys(treeData.members).length === 0) {
alert('没有成员数据可导出')
await showAlert('没有成员数据可导出', "提示")
return
}
@@ -655,9 +657,9 @@ export default function TreePage() {
await exportFamilyHTML(members, getMemberName, treeName, baseUrl)
} catch (error) {
console.error('导出PDF失败:', error)
alert('导出失败,请重试')
await showAlert('导出失败,请重试', "错误")
}
}, [treeData.members, currentTree?.name])
}, [treeData.members, currentTree?.name, showAlert])
// 未登录时不渲染内容(已在 useEffect 中重定向)
if (status === 'loading' || !session) {