0.0.3.0
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user