436 lines
13 KiB
TypeScript
436 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { createContext, useContext, useState, useCallback, useEffect, useRef, Suspense, useMemo } from "react"
|
|
import type { FamilyMember, FamilyTreeData } from "@/types/family"
|
|
import { useSession } from "next-auth/react"
|
|
import { useSearchParams } from "next/navigation"
|
|
|
|
interface FamilyTreeInfo {
|
|
id: string
|
|
name: string
|
|
description: string | null
|
|
ownerId: string
|
|
rootMemberId: string | null
|
|
currentUserRole?: "OWNER" | "EDITOR" | "VIEWER"
|
|
}
|
|
|
|
interface FamilyContextType {
|
|
treeData: FamilyTreeData
|
|
currentTree: FamilyTreeInfo | null
|
|
getMember: (id: string) => FamilyMember | undefined
|
|
addMember: (member: FamilyMember) => Promise<void>
|
|
updateMember: (id: string, updates: Partial<FamilyMember>) => Promise<void>
|
|
deleteMember: (id: string) => Promise<void>
|
|
searchResults: FamilyMember[]
|
|
searchMembers: (query: string) => void
|
|
loadData: (data: FamilyTreeData) => Promise<void>
|
|
resetData: () => Promise<void>
|
|
isLoading: boolean
|
|
highlightedMemberId: string | null
|
|
setHighlightedMemberId: (id: string | null) => void
|
|
}
|
|
|
|
const FamilyContext = createContext<FamilyContextType | undefined>(undefined)
|
|
|
|
function FamilyProviderInner({ children }: { children: React.ReactNode }) {
|
|
const { data: session, status } = useSession()
|
|
const searchParams = useSearchParams()
|
|
const [searchResults, setSearchResults] = useState<FamilyMember[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [highlightedMemberId, setHighlightedMemberId] = useState<string | null>(null)
|
|
const [treeData, setTreeData] = useState<FamilyTreeData>({
|
|
rootId: "",
|
|
members: {},
|
|
})
|
|
const [currentTree, setCurrentTree] = useState<FamilyTreeInfo | null>(null)
|
|
const loadingTreeIdRef = useRef<string | null>(null)
|
|
|
|
// 监听 URL 和 Session 变化,加载家族树
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
const signal = controller.signal
|
|
|
|
const loadTree = async () => {
|
|
const treeId = searchParams.get('treeId')
|
|
|
|
// 如果 session 正在加载,等待
|
|
if (status === 'loading') {
|
|
return
|
|
}
|
|
|
|
// 如果 session 不存在(未登录),清除所有数据
|
|
if (!session?.user?.id) {
|
|
if (!signal.aborted) {
|
|
setCurrentTree(null)
|
|
setTreeData({ rootId: "", members: {} })
|
|
setIsLoading(false)
|
|
loadingTreeIdRef.current = null
|
|
}
|
|
return
|
|
}
|
|
|
|
// 如果 URL 中有 treeId,使用它
|
|
if (treeId) {
|
|
// 如果正在加载相同的树,跳过
|
|
if (loadingTreeIdRef.current === treeId) {
|
|
return
|
|
}
|
|
|
|
// 如果 treeId 与当前树相同,跳过
|
|
if (treeId === currentTree?.id) {
|
|
return
|
|
}
|
|
} else {
|
|
// URL 中没有 treeId
|
|
// 如果已经有当前树,保持不变(避免在页面跳转时丢失上下文)
|
|
if (currentTree?.id) {
|
|
return
|
|
}
|
|
// 如果没有当前树,加载第一个
|
|
}
|
|
|
|
const targetTreeId = treeId || 'first'
|
|
|
|
// 如果正在加载相同的树,跳过
|
|
if (loadingTreeIdRef.current === targetTreeId) {
|
|
return
|
|
}
|
|
|
|
// 标记正在加载
|
|
loadingTreeIdRef.current = targetTreeId
|
|
|
|
if (!signal.aborted) setIsLoading(true)
|
|
|
|
const fetchUrl = targetTreeId === 'first'
|
|
? '/api/trees'
|
|
: `/api/trees/${targetTreeId}`
|
|
|
|
try {
|
|
const res = await fetch(fetchUrl, { signal })
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP error! status: ${res.status}`)
|
|
}
|
|
const data = await res.json()
|
|
|
|
if (signal.aborted) return
|
|
|
|
let tree = null
|
|
if (targetTreeId === 'first') {
|
|
tree = data.trees?.[0]
|
|
} else {
|
|
tree = data.tree
|
|
}
|
|
|
|
if (tree) {
|
|
setCurrentTree(tree)
|
|
// 加载该树的成员数据
|
|
try {
|
|
const memberRes = await fetch(`/api/trees/${tree.id}/members`, { signal })
|
|
if (!memberRes.ok) {
|
|
throw new Error(`Failed to load members: ${memberRes.status}`)
|
|
}
|
|
const memberData = await memberRes.json()
|
|
|
|
if (signal.aborted) return
|
|
|
|
if (memberData.members) {
|
|
const membersMap = memberData.members.reduce(
|
|
(acc: Record<string, FamilyMember>, member: FamilyMember) => {
|
|
acc[member.id] = member
|
|
return acc
|
|
},
|
|
{}
|
|
)
|
|
setTreeData({
|
|
rootId: memberData.rootId || "",
|
|
members: membersMap,
|
|
})
|
|
}
|
|
} catch (err: any) {
|
|
if (err.name === 'AbortError') {
|
|
return
|
|
}
|
|
console.error('加载成员数据失败:', err)
|
|
// 即使成员加载失败,也保留树的信息
|
|
setTreeData({ rootId: "", members: {} })
|
|
}
|
|
} else {
|
|
setCurrentTree(null)
|
|
setTreeData({ rootId: "", members: {} })
|
|
}
|
|
} catch (err: any) {
|
|
if (err.name === 'AbortError') {
|
|
return
|
|
}
|
|
|
|
// 忽略 Failed to fetch 错误
|
|
if (err.message === 'Failed to fetch' || err.toString().includes('Failed to fetch')) {
|
|
return
|
|
}
|
|
|
|
console.error('加载家族树失败:', err.message || err)
|
|
setCurrentTree(null)
|
|
setTreeData({ rootId: "", members: {} })
|
|
} finally {
|
|
if (!signal.aborted) {
|
|
setIsLoading(false)
|
|
// 加载完成后清除标记
|
|
loadingTreeIdRef.current = null
|
|
}
|
|
}
|
|
}
|
|
|
|
loadTree()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [searchParams, session?.user?.id, status])
|
|
|
|
const getMember = useCallback(
|
|
(id: string) => {
|
|
return treeData.members[id]
|
|
},
|
|
[treeData],
|
|
)
|
|
|
|
const addMember = useCallback(async (member: FamilyMember) => {
|
|
if (!currentTree?.id) throw new Error('没有选择家族树')
|
|
|
|
const response = await fetch(`/api/trees/${currentTree.id}/members`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(member),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || '添加成员失败')
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
// 更新本地状态
|
|
setTreeData(prev => ({
|
|
...prev,
|
|
members: {
|
|
...prev.members,
|
|
[data.member.id]: data.member,
|
|
},
|
|
}))
|
|
}, [currentTree?.id])
|
|
|
|
const updateMember = useCallback(async (id: string, updates: Partial<FamilyMember>) => {
|
|
if (!currentTree?.id) throw new Error('没有选择家族树')
|
|
|
|
// 将 undefined 转为 null,确保 JSON 序列化时能正确传递给后端
|
|
// 否则 JSON.stringify 会忽略 undefined 值,导致后端收不到更新
|
|
const payload = Object.fromEntries(
|
|
Object.entries(updates).map(([key, value]) => [key, value === undefined ? null : value])
|
|
)
|
|
|
|
const response = await fetch(`/api/trees/${currentTree.id}/members/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || '更新成员失败')
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
// 由于后端会维护双向关系,需要重新加载所有成员以保持数据一致性
|
|
// 特别是当修改了关系字段(spouseIds, childrenIds, fatherId, motherId)时
|
|
const hasRelationshipChanges =
|
|
updates.spouseIds !== undefined ||
|
|
updates.childrenIds !== undefined ||
|
|
updates.fatherId !== undefined ||
|
|
updates.motherId !== undefined
|
|
|
|
if (hasRelationshipChanges) {
|
|
// 重新加载所有成员数据以同步双向关系
|
|
const membersResponse = await fetch(`/api/trees/${currentTree.id}/members`)
|
|
if (membersResponse.ok) {
|
|
const membersData = await membersResponse.json()
|
|
const membersMap: Record<string, FamilyMember> = {}
|
|
membersData.members.forEach((m: FamilyMember) => {
|
|
membersMap[m.id] = m
|
|
})
|
|
setTreeData(prev => ({
|
|
...prev,
|
|
members: membersMap,
|
|
}))
|
|
return
|
|
}
|
|
}
|
|
|
|
// 如果没有关系变更,只更新当前成员
|
|
setTreeData(prev => ({
|
|
...prev,
|
|
members: {
|
|
...prev.members,
|
|
[id]: data.member,
|
|
},
|
|
}))
|
|
}, [currentTree?.id])
|
|
|
|
const deleteMember = useCallback(async (id: string) => {
|
|
if (!currentTree?.id) throw new Error('没有选择家族树')
|
|
|
|
const response = await fetch(`/api/trees/${currentTree.id}/members/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || '删除成员失败')
|
|
}
|
|
|
|
// 更新本地状态
|
|
setTreeData(prev => {
|
|
const newMembers = { ...prev.members }
|
|
delete newMembers[id]
|
|
return {
|
|
...prev,
|
|
members: newMembers,
|
|
}
|
|
})
|
|
}, [currentTree?.id])
|
|
|
|
const loadData = useCallback(async (data: FamilyTreeData) => {
|
|
if (!currentTree?.id) throw new Error('没有选择家族树')
|
|
|
|
// 导入数据到服务器
|
|
const response = await fetch(`/api/trees/${currentTree.id}/import`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || '导入数据失败')
|
|
}
|
|
|
|
// 重新加载数据
|
|
const membersResponse = await fetch(`/api/trees/${currentTree.id}/members`)
|
|
const membersData = await membersResponse.json()
|
|
|
|
if (membersData.members) {
|
|
const membersMap = membersData.members.reduce(
|
|
(acc: Record<string, FamilyMember>, member: FamilyMember) => {
|
|
acc[member.id] = member
|
|
return acc
|
|
},
|
|
{}
|
|
)
|
|
setTreeData({
|
|
rootId: membersData.rootId || "",
|
|
members: membersMap,
|
|
})
|
|
}
|
|
}, [currentTree?.id])
|
|
|
|
const resetData = useCallback(async () => {
|
|
// 重置为空数据
|
|
setTreeData({ rootId: "", members: {} })
|
|
}, [])
|
|
|
|
const searchMembers = useCallback(
|
|
(query: string) => {
|
|
if (!query.trim()) {
|
|
setSearchResults([])
|
|
return
|
|
}
|
|
|
|
const lowerQuery = query.toLowerCase()
|
|
// 在内存中的 treeData 上执行搜索,简单快速
|
|
const results = Object.values(treeData.members).filter(
|
|
(member) =>
|
|
member.fullName.includes(lowerQuery) ||
|
|
member.givenName.includes(lowerQuery) ||
|
|
member.courtesyName?.includes(lowerQuery) ||
|
|
member.artName?.includes(lowerQuery),
|
|
)
|
|
setSearchResults(results)
|
|
},
|
|
[treeData],
|
|
)
|
|
|
|
// Listen for import events
|
|
useEffect(() => {
|
|
const handleImport = (e: CustomEvent<FamilyTreeData>) => {
|
|
loadData(e.detail)
|
|
}
|
|
window.addEventListener("family-data-import" as any, handleImport as any)
|
|
return () => window.removeEventListener("family-data-import" as any, handleImport as any)
|
|
}, [loadData])
|
|
|
|
// 监听 treeData 变化
|
|
// useEffect(() => {
|
|
// console.log('🔄 FamilyContext treeData 更新:', {
|
|
// rootId: treeData.rootId,
|
|
// membersCount: Object.keys(treeData.members).length,
|
|
// memberIds: Object.keys(treeData.members),
|
|
// currentTreeId: currentTree?.id,
|
|
// currentTreeName: currentTree?.name
|
|
// })
|
|
// }, [treeData, currentTree])
|
|
|
|
// 使用 useMemo 缓存 context value,避免不必要的重渲染
|
|
const contextValue = useMemo(() => ({
|
|
treeData,
|
|
currentTree,
|
|
getMember,
|
|
addMember,
|
|
updateMember,
|
|
deleteMember,
|
|
searchResults,
|
|
searchMembers,
|
|
loadData,
|
|
resetData,
|
|
isLoading,
|
|
highlightedMemberId,
|
|
setHighlightedMemberId,
|
|
}), [
|
|
treeData,
|
|
currentTree,
|
|
getMember,
|
|
addMember,
|
|
updateMember,
|
|
deleteMember,
|
|
searchResults,
|
|
searchMembers,
|
|
loadData,
|
|
resetData,
|
|
isLoading,
|
|
highlightedMemberId,
|
|
])
|
|
|
|
return (
|
|
<FamilyContext.Provider value={contextValue}>
|
|
{children}
|
|
</FamilyContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function FamilyProvider({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<FamilyProviderInner>{children}</FamilyProviderInner>
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
export function useFamily() {
|
|
const context = useContext(FamilyContext)
|
|
if (context === undefined) {
|
|
throw new Error("useFamily must be used within a FamilyProvider")
|
|
}
|
|
return context
|
|
}
|