This commit is contained in:
freedakgmail
2025-11-23 13:31:06 +08:00
parent 00cfae381b
commit 2b4bc92f1b
1039 changed files with 947611 additions and 1307 deletions
+20
View File
@@ -124,6 +124,18 @@ export default function MemberProfilePage() {
)}
{member.ancestralHome && <p>{member.ancestralHome}</p>}
</div>
{member.tags && member.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{member.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-primary/10 text-primary text-xs font-medium rounded-md border border-primary/20"
>
{tag}
</span>
))}
</div>
)}
</div>
</div>
@@ -212,6 +224,10 @@ export default function MemberProfilePage() {
>
{getMember(member.fatherId)?.fullName || member.fatherId}
</Link>
) : member.spouseFatherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseFatherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
@@ -227,6 +243,10 @@ export default function MemberProfilePage() {
>
{getMember(member.motherId)?.fullName || member.motherId}
</Link>
) : member.spouseMotherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseMotherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
+78 -22
View File
@@ -279,19 +279,42 @@ export default function MembersPage() {
<div className="flex-1 h-px bg-border"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{members.map((member) => (
{members.map((member) => {
// 判断是否是配偶:
// 1. 没有父母ID
// 2. 有配偶ID
// 3. 不是始祖(没有'始祖'标签)
const isFounder = member.tags?.includes('始祖')
const isSpouse = !member.fatherId && !member.motherId &&
member.spouseIds && member.spouseIds.length > 0 &&
!isFounder
// 获取配偶信息
const spouse = isSpouse && member.spouseIds?.[0] ? allMembers.find(m => m.id === member.spouseIds[0]) : null
const spouseName = spouse ? spouse.fullName : null
return (
<div key={member.id} className="relative group">
<Link href={`/members/${member.id}`}>
<Card className="hover:border-primary transition-colors cursor-pointer h-full">
<CardContent className="p-3 flex items-start gap-2.5">
<AvatarDisplay
imageId={member.avatarImageId}
fallbackUrl={member.avatarUrl}
fallbackText={member.surname}
gender={member.gender}
className="h-10 w-10 border border-border"
/>
<div className="flex-1 min-w-0">
<CardContent className="p-3 flex items-center gap-3">
<div className="relative flex-shrink-0">
<AvatarDisplay
imageId={member.avatarImageId}
fallbackUrl={member.avatarUrl}
fallbackText={member.surname}
gender={member.gender}
className="h-20 w-20 border-2 border-border"
/>
{/* 在世/去世标识 */}
{member.deathDate && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-gray-500 border-2 border-white" title="已故" />
)}
{!member.deathDate && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500 border-2 border-white" title="在世" />
)}
</div>
<div className="flex-1 min-w-0 pl-1">
<div className="flex items-baseline justify-between gap-1.5">
<h3 className="text-base font-bold font-serif truncate">
{member.fullName}
@@ -302,18 +325,50 @@ export default function MembersPage() {
)}
</h3>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
{member.generationName ? `字辈: ${member.generationName}` : "未录入字辈"}
</p>
<p className="text-[11px] text-muted-foreground truncate">{member.ancestralHome || "籍贯未知"}</p>
{(member.deathDate || member.tags?.length) && (
{isFounder ? (
<p className="text-[11px] text-amber-700 font-medium mt-0.5 truncate">
</p>
) : isSpouse && spouseName ? (
<p className="text-[11px] text-purple-700 mt-0.5 truncate">
{spouseName}
</p>
) : (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
{member.generationName ? `字辈: ${member.generationName}` : "未录入字辈"}
</p>
)}
{/* 籍贯和生卒年并排 */}
<div className="flex items-start justify-between gap-2 mt-0.5">
<p className="text-[11px] text-muted-foreground truncate flex-1">{member.ancestralHome || "籍贯未知"}</p>
{/* 生卒年和年龄 */}
{(member.birthDate || member.deathDate) && (
<div className="text-[10px] text-muted-foreground text-right space-y-0.5 flex-shrink-0">
<div>
{member.birthDate && new Date(member.birthDate).getFullYear()}
{member.deathDate && ` - ${new Date(member.deathDate).getFullYear()}`}
{!member.deathDate && member.birthDate && ' - '}
</div>
{(() => {
if (!member.birthDate) return null
const birthYear = new Date(member.birthDate).getFullYear()
const deathYear = member.deathDate ? new Date(member.deathDate).getFullYear() : new Date().getFullYear()
const age = deathYear - birthYear
return (
<div className="text-[9px] text-muted-foreground">
{member.deathDate ? `享年${age}` : `${age}`}
</div>
)
})()}
</div>
)}
</div>
{member.tags && member.tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{member.deathDate && (
<span className="text-[9px] bg-neutral-100 text-neutral-600 px-1 py-0.5 rounded border border-neutral-200">
</span>
)}
{member.tags?.map((tag) => (
{member.tags.map((tag) => (
<span
key={tag}
className="text-[9px] bg-primary/10 text-primary px-1 py-0.5 rounded border border-primary/20"
@@ -340,7 +395,8 @@ export default function MembersPage() {
<Search className="h-3 w-3" />
</Button>
</div>
))}
)
})}
</div>
</div>
))}
+131 -36
View File
@@ -4,19 +4,41 @@ import type React from "react"
import { SiteHeader } from "@/components/site-header"
import { TreeLayout } from "@/components/tree/tree-layout"
import { D3OrgChartFlow, type D3OrgChartRef } from "@/components/tree/d3-org-chart-flow"
import { useFamily } from "@/context/family-context"
import { Button } from "@/components/ui/button"
import { ZoomIn, ZoomOut, Move, Filter, Download } from "lucide-react"
import { useState, useRef } from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown } from "lucide-react"
import { useState, useRef, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
type ViewMode = "traditional" | "d3-org-chart"
export default function TreePage() {
const { treeData } = useFamily()
const router = useRouter()
const searchParams = useSearchParams()
const [viewMode, setViewMode] = useState<ViewMode>("traditional")
// 从 URL 参数读取视图模式
useEffect(() => {
const view = searchParams.get('view')
if (view === 'd3-org-chart' || view === 'traditional') {
setViewMode(view)
}
}, [searchParams])
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [dragStartPos, setDragStartPos] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const d3ChartRef = useRef<D3OrgChartRef>(null)
const handleMouseDown = (e: React.MouseEvent) => {
// 如果点击的是节点卡片,不启动拖拽
@@ -173,43 +195,116 @@ export default function TreePage() {
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
{/* Toolbar */}
<div className="absolute top-20 left-4 z-20 flex flex-col gap-2 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.min(s + 0.1, 2))}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.max(s - 0.1, 0.1))}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setPosition({ x: 0, y: 0 })}>
<Move className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon" onClick={handleExportImage} title="导出为图片">
<Download className="h-4 w-4" />
</Button>
{/* 视图切换按钮 */}
<div className="absolute top-20 right-4 z-30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 shadow-lg">
<LayoutGrid className="h-4 w-4" />
{viewMode === 'traditional' ? '传统视图' : 'D3家谱图'}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => {
const params = new URLSearchParams(searchParams.toString())
params.set('view', 'traditional')
router.push(`/tree?${params.toString()}`)
}}
className={viewMode === 'traditional' ? 'bg-accent' : ''}
>
<div className="flex items-center justify-between w-full">
<span></span>
{viewMode === 'traditional' && <span className="text-primary"></span>}
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const params = new URLSearchParams(searchParams.toString())
params.set('view', 'd3-org-chart')
router.push(`/tree?${params.toString()}`)
}}
className={viewMode === 'd3-org-chart' ? 'bg-accent' : ''}
>
<div className="flex items-center justify-between w-full">
<span>D3 </span>
{viewMode === 'd3-org-chart' && <span className="text-primary"></span>}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tree Canvas */}
<div
ref={containerRef}
className="flex-1 overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: "top center",
transition: isDragging ? "none" : "transform 0.1s ease-out",
}}
className="absolute min-w-full min-h-full flex justify-center pt-20 pb-20"
>
<TreeLayout rootId={treeData.rootId} />
{viewMode === 'traditional' ? (
<div className="flex-1 relative">
{/* Toolbar */}
<div className="absolute top-4 left-4 z-20 flex flex-col gap-2 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.min(s + 0.1, 2))}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.max(s - 0.1, 0.1))}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setPosition({ x: 0, y: 0 })}>
<Move className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon" onClick={handleExportImage} title="导出为图片">
<Download className="h-4 w-4" />
</Button>
</div>
{/* Tree Canvas */}
<div
ref={containerRef}
className="w-full h-full overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: "top center",
transition: isDragging ? "none" : "transform 0.1s ease-out",
}}
className="absolute min-w-full min-h-full flex justify-center pt-20 pb-20"
>
<TreeLayout rootId={treeData.rootId} />
</div>
</div>
</div>
</div>
) : (
<div className="flex-1 relative">
{/* 统一的控制按钮 */}
<div className="absolute top-4 left-4 z-20 flex flex-col gap-2 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.expandAll()} title="展开全部">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.collapseAll()} title="折叠全部">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.fitView()} title="适应视图">
<Move className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.exportPNG()} title="导出PNG">
<Download className="h-4 w-4" />
</Button>
</div>
<div className="w-full h-full p-4">
<D3OrgChartFlow
ref={d3ChartRef}
members={treeData.members}
rootId={treeData.rootId}
onMemberClick={(id: string) => router.push(`/members/${id}`)}
/>
</div>
</div>
)}
</div>
)
}