This commit is contained in:
freedakgmail
2025-11-30 09:16:43 +08:00
parent b436ae0680
commit fd9c064749
227 changed files with 1251 additions and 912 deletions
+26 -4
View File
@@ -1,10 +1,32 @@
"use client"
import { useEffect, useRef } from "react"
import * as echarts from "echarts"
// 显式导入 ECharts 核心和渲染器
import * as echarts from "echarts/core"
import { PieChart, BarChart, LineChart } from "echarts/charts"
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
} from "echarts/components"
import { CanvasRenderer } from "echarts/renderers"
import { graphic } from "echarts/core"
import type { FamilyMember } from "@/types/family"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
// 注册必要的组件
echarts.use([
PieChart,
BarChart,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
CanvasRenderer,
])
interface StatisticsChartsProps {
members: FamilyMember[]
}
@@ -133,7 +155,7 @@ export function StatisticsCharts({ members }: StatisticsChartsProps) {
type: 'bar',
data: sortedGenerations.map(([, count]) => count),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
color: new graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
@@ -141,7 +163,7 @@ export function StatisticsCharts({ members }: StatisticsChartsProps) {
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
color: new graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2378f7' },
{ offset: 0.7, color: '#2378f7' },
{ offset: 1, color: '#83bff6' }
@@ -285,7 +307,7 @@ export function StatisticsCharts({ members }: StatisticsChartsProps) {
data: sortedYears.map(([, count]) => count),
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
color: new graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(99, 102, 241, 0.5)' },
{ offset: 1, color: 'rgba(99, 102, 241, 0.1)' }
])
+1 -1
View File
@@ -287,7 +287,7 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
<div className="flex justify-center mb-4">
<ImageUpload
value={formData.avatarUrl}
onChange={(imageUrl) => handleChange("avatarUrl", imageUrl)}
onChange={(imageUrl) => handleChange("avatarUrl", imageUrl || null)}
/>
</div>
+15 -8
View File
@@ -26,15 +26,16 @@ interface MemberVersionHistoryProps {
memberId: string
treeId: string
onVersionSelect?: (version: MemberHistory) => void
lastUpdated?: number // 用于触发刷新的时间戳
}
export function MemberVersionHistory({ memberId, treeId, onVersionSelect }: MemberVersionHistoryProps) {
export function MemberVersionHistory({ memberId, treeId, onVersionSelect, lastUpdated }: MemberVersionHistoryProps) {
const [history, setHistory] = useState<MemberHistory[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadHistory()
}, [memberId, treeId])
}, [memberId, treeId, lastUpdated])
const loadHistory = async () => {
try {
@@ -80,12 +81,18 @@ export function MemberVersionHistory({ memberId, treeId, onVersionSelect }: Memb
motherId: '母亲', fatherId: '父亲', isFounder: '始祖',
isLunarDate: '农历日期', burialPlace: '安葬地', tags: '标签',
spouseFatherName: '岳父/公公', spouseMotherName: '岳母/婆婆',
avatarUrl: '头像', stories: '家族故事',
}
const formatValue = (value: any) => {
const formatValue = (value: any, field?: string) => {
if (value === null || value === undefined) return '-'
if (typeof value === 'boolean') return value ? '是' : '否'
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '-'
if (Array.isArray(value)) {
if (field === 'photoIds') return `${value.length} 张照片`
if (field === 'stories') return `${value.length} 个故事`
return value.length > 0 ? value.join(', ') : '-'
}
if (field === 'avatarUrl') return value ? '已上传' : '无'
return String(value)
}
@@ -125,9 +132,9 @@ export function MemberVersionHistory({ memberId, treeId, onVersionSelect }: Memb
<span className="text-muted-foreground min-w-[60px]">{fieldLabels[field]}:</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-red-600 line-through">{formatValue(change.old)}</span>
<span className="text-red-600 line-through">{formatValue(change.old, field)}</span>
<span className="text-muted-foreground"></span>
<span className="text-green-600 font-medium">{formatValue(change.new)}</span>
<span className="text-green-600 font-medium">{formatValue(change.new, field)}</span>
</div>
</div>
</div>
@@ -146,9 +153,9 @@ export function MemberVersionHistory({ memberId, treeId, onVersionSelect }: Memb
<span className="text-muted-foreground min-w-[60px]">{fieldLabels[field]}:</span>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-red-600 line-through">{formatValue(change.old)}</span>
<span className="text-red-600 line-through">{formatValue(change.old, field)}</span>
<span className="text-muted-foreground"></span>
<span className="text-green-600 font-medium">{formatValue(change.new)}</span>
<span className="text-green-600 font-medium">{formatValue(change.new, field)}</span>
</div>
</div>
</div>
+13 -4
View File
@@ -15,7 +15,8 @@ import {
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { useFamily } from "@/context/family-context"
import { useState, useRef, useEffect, useCallback } from "react"
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { useDebouncedCallback } from "@/hooks/use-debounce"
import { useRouter, usePathname, useSearchParams } from "next/navigation"
import { useSession, signOut } from "next-auth/react"
import { useDialog } from "@/components/ui/alert-dialog-custom"
@@ -101,7 +102,10 @@ export function SiteHeader() {
}
const handleSignOut = async () => {
await signOut({ callbackUrl: "/auth/signin" })
const confirmed = await showConfirm("确定要退出登录吗?", "退出登录", "destructive")
if (confirmed) {
await signOut({ callbackUrl: "/auth/signin" })
}
}
// 获取用户名首字母作为头像
@@ -219,10 +223,15 @@ export function SiteHeader() {
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const handleSearch = (query: string) => {
setSearchQuery(query)
// 使用防抖优化搜索性能
const debouncedSearch = useDebouncedCallback((query: string) => {
searchMembers(query)
setShowResults(query.length > 0)
}, 300)
const handleSearch = (query: string) => {
setSearchQuery(query)
debouncedSearch(query)
}
const handleSelectMember = (memberId: string) => {
+33 -25
View File
@@ -2,7 +2,7 @@
import type { FamilyMember } from "@/types/family"
import { FamilyNode } from "./family-node"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback } from "react"
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
import { useRouter } from "next/navigation"
interface TreeLayoutProps {
@@ -131,10 +131,33 @@ export function TreeLayout({
}
}, [calculateConnections, treeData])
// 预计算排序后的子节点映射,避免每次渲染时重复计算
const sortedChildrenMap = useMemo(() => {
const map = new Map<string, string[]>()
Object.values(treeData.members).forEach(member => {
if (member.childrenIds && member.childrenIds.length > 0) {
const sorted = [...member.childrenIds].sort((aId, bId) => {
const a = treeData.members[aId]
const b = treeData.members[bId]
if (!a || !b) return 0
const aIsFounder = a.tags?.includes('始祖') || a.generation === 1
const bIsFounder = b.tags?.includes('始祖') || b.generation === 1
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
return (a.birthDate || "").localeCompare(b.birthDate || "")
})
map.set(member.id, sorted)
}
})
return map
}, [treeData.members])
if (!root) return null
// 递归渲染树节点
const TreeNode = ({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) => {
// 递归渲染树节点 - 使用 memo 优化
const TreeNode = memo(function TreeNode({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) {
const member = getMember(memberId)
if (!member) return null
@@ -145,6 +168,9 @@ export function TreeLayout({
? member.spouseIds.map(id => getMember(id)).filter(Boolean) as FamilyMember[]
: []
// 使用预计算的排序结果
const sortedChildren = sortedChildrenMap.get(memberId) || []
return (
<div className="flex flex-col items-center">
{/* 节点本身 */}
@@ -178,32 +204,14 @@ export function TreeLayout({
gap: `${HORIZONTAL_GAP}px`
}}
>
{(() => {
// 对子节点进行排序:始祖排最左边,其他按出生日期排序
const sortedChildren = [...member.childrenIds].sort((aId, bId) => {
const a = getMember(aId)
const b = getMember(bId)
if (!a || !b) return 0
// 始祖永远排最左边
const aIsFounder = a.tags?.includes('始祖') || a.generation === 1
const bIsFounder = b.tags?.includes('始祖') || b.generation === 1
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
// 其他按出生日期排序
return (a.birthDate || "").localeCompare(b.birthDate || "")
})
return sortedChildren.map((childId) => (
<TreeNode key={childId} memberId={childId} />
))
})()}
{sortedChildren.map((childId) => (
<TreeNode key={childId} memberId={childId} />
))}
</div>
)}
</div>
)
}
})
return (
<div className="relative">