This commit is contained in:
freedakgmail
2025-12-10 15:21:27 +08:00
parent e7167f4340
commit 1ae59960e8
148 changed files with 681 additions and 477 deletions
+163 -55
View File
@@ -51,6 +51,7 @@ export default function TreePage() {
const router = useRouter()
const searchParams = useSearchParams()
const [viewMode, setViewMode] = useState<ViewMode>("traditional")
const [isIOSDevice, setIsIOSDevice] = useState(false)
// 关系查询模式
const [relationMode, setRelationMode] = useState(false)
@@ -81,12 +82,28 @@ export default function TreePage() {
}
}, [currentTree?.id, refreshData])
// 从 URL 参数读取视图模式
// 从 URL 参数读取视图模式iOS 触摸设备默认使用传统族谱
useEffect(() => {
if (!searchParams) return
const view = searchParams.get('view')
if (view === 'd3-org-chart' || view === 'traditional') {
// 检测 iOS 触摸设备(iPad/iPhone 上的所有浏览器)
const isTouchDevice = typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1
const isIOS = typeof navigator !== 'undefined' && (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && isTouchDevice) ||
/CriOS/.test(navigator.userAgent) // iOS Chrome
)
// 设置 iOS 设备状态
setIsIOSDevice(isIOS)
if (view === 'd3-org-chart' && !isIOS) {
// 非 iOS 设备可以使用 D3
setViewMode(view)
} else if (view === 'traditional' || isIOS) {
// iOS 设备强制使用传统族谱
setViewMode('traditional')
}
}, [searchParams])
const [scale, setScale] = useState(1)
@@ -261,12 +278,91 @@ export default function TreePage() {
setIsDragging(false)
}
// 鼠标滚轮缩放
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
setScale((s) => Math.min(Math.max(s + delta, 0.1), 2))
}
// 触摸事件状态
const touchStartRef = useRef<{ x: number; y: number; distance?: number } | null>(null)
const positionRef = useRef(position)
const scaleRef = useRef(scale)
// 保持 ref 同步
useEffect(() => {
positionRef.current = position
}, [position])
useEffect(() => {
scaleRef.current = scale
}, [scale])
// 使用原生事件监听器处理触摸和滚轮事件(避免 passive 事件问题)
useEffect(() => {
const container = containerRef.current
if (!container) return
// 鼠标滚轮缩放
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
setScale((s) => Math.min(Math.max(s + delta, 0.1), 2))
}
// 计算两指间距离
const getTouchDistance = (touches: TouchList) => {
if (touches.length < 2) return 0
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
// 触摸开始
const handleTouchStart = (e: globalThis.TouchEvent) => {
if (e.touches.length === 1) {
touchStartRef.current = {
x: e.touches[0].clientX - positionRef.current.x,
y: e.touches[0].clientY - positionRef.current.y
}
} else if (e.touches.length === 2) {
touchStartRef.current = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
distance: getTouchDistance(e.touches)
}
}
}
// 触摸移动
const handleTouchMove = (e: globalThis.TouchEvent) => {
if (!touchStartRef.current) return
e.preventDefault()
if (e.touches.length === 1 && !touchStartRef.current.distance) {
const newX = e.touches[0].clientX - touchStartRef.current.x
const newY = e.touches[0].clientY - touchStartRef.current.y
setPosition({ x: newX, y: newY })
} else if (e.touches.length === 2 && touchStartRef.current.distance) {
const newDistance = getTouchDistance(e.touches)
const scaleFactor = newDistance / touchStartRef.current.distance
setScale(s => Math.min(Math.max(s * scaleFactor, 0.1), 2))
touchStartRef.current.distance = newDistance
}
}
// 触摸结束
const handleTouchEnd = () => {
touchStartRef.current = null
}
// 添加事件监听器,设置 passive: false
container.addEventListener('wheel', handleWheel, { passive: false })
container.addEventListener('touchstart', handleTouchStart, { passive: true })
container.addEventListener('touchmove', handleTouchMove, { passive: false })
container.addEventListener('touchend', handleTouchEnd, { passive: true })
return () => {
container.removeEventListener('wheel', handleWheel)
container.removeEventListener('touchstart', handleTouchStart)
container.removeEventListener('touchmove', handleTouchMove)
container.removeEventListener('touchend', handleTouchEnd)
}
}, [viewMode])
// 处理成员选择(关系查询模式)
const handleMemberClick = (memberId: string) => {
@@ -589,28 +685,36 @@ export default function TreePage() {
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
{/* 视图切换和关系查询按钮 */}
<div className="absolute top-20 right-4 z-30 flex gap-2">
{/* 视图切换和关系查询按钮 - 移动端自适应 */}
<div className="absolute top-16 md:top-20 right-2 md:right-4 z-30 flex gap-1 md:gap-2">
{/* 关系查询按钮 */}
<Button
variant={relationMode ? "default" : "outline"}
className="gap-2 shadow-lg"
className="gap-1 md:gap-2 shadow-lg text-xs md:text-sm px-2 md:px-4 h-8 md:h-10"
onClick={toggleRelationMode}
>
<Users className="h-4 w-4" />
{relationMode ? (
selectedMembers.length === 0 ? '选择第一个成员' : '选择第二个成员'
) : '关系查询'}
<Users className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">
{relationMode ? (
selectedMembers.length === 0 ? '选择第一个成员' : '选择第二个成员'
) : '关系查询'}
</span>
<span className="sm:hidden">
{relationMode ? '选择' : '查询'}
</span>
</Button>
<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>
{/* iOS 设备只显示传统族谱,不显示切换按钮 */}
{!isIOSDevice && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-1 md:gap-2 shadow-lg text-xs md:text-sm px-2 md:px-4 h-8 md:h-10">
<LayoutGrid className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">{viewMode === 'traditional' ? '传统族谱' : 'D3族谱'}</span>
<span className="sm:hidden">{viewMode === 'traditional' ? '传统' : 'D3'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => {
@@ -625,44 +729,48 @@ export default function TreePage() {
{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>
{/* iOS 设备不显示 D3 选项(foreignObject 渲染问题) */}
{!isIOSDevice && (
<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>
{viewMode === 'traditional' ? (
<div className="flex-1 relative min-h-0">
{/* 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))}>
{/* Toolbar - 移动端底部横向,桌面端左侧纵向 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 md:bottom-auto md:top-4 md:left-4 md:translate-x-0 z-20 flex flex-row md:flex-col gap-1 md:gap-2 bg-background/90 backdrop-blur p-1.5 md:p-2 rounded-lg border shadow-lg">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" 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))}>
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setScale((s) => Math.max(s - 0.1, 0.1))}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleTraditionalFitView} title="自适应视图">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleTraditionalFitView} title="自适应视图">
<Move className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setExpandAll(true)} title="完全展开">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => setExpandAll(true)} title="完全展开">
<Maximize2 className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon" onClick={handleExportImage} title="导出为图片">
<div className="w-px md:w-auto h-6 md:h-px bg-border mx-1 md:mx-0 md:my-1" />
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleExportImage} title="导出为图片">
<Download className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleExportPDF} title="导出族谱PDF">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleExportPDF} title="导出族谱PDF">
<FileText className="h-4 w-4" />
</Button>
</div>
@@ -675,7 +783,6 @@ export default function TreePage() {
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
<div
style={{
@@ -699,22 +806,22 @@ export default function TreePage() {
</div>
) : (
<div className="flex-1 relative min-h-0">
{/* 统一的控制按钮 */}
<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="完全展开">
{/* 统一的控制按钮 - 移动端底部横向,桌面端左侧纵向 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 md:bottom-auto md:top-4 md:left-4 md:translate-x-0 z-20 flex flex-row md:flex-col gap-1 md:gap-2 bg-background/90 backdrop-blur p-1.5 md:p-2 rounded-lg border shadow-lg">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.expandAll()} title="完全展开">
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.collapseAll()} title="折叠全部">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.collapseAll()} title="折叠全部">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => d3ChartRef.current?.fitView()} title="适应视图">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" 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">
<div className="w-px md:w-auto h-6 md:h-px bg-border mx-1 md:mx-0 md:my-1" />
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={() => d3ChartRef.current?.exportPNG()} title="导出PNG">
<Download className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleExportPDF} title="导出族谱PDF">
<Button variant="outline" size="icon" className="h-9 w-9 md:h-10 md:w-10" onClick={handleExportPDF} title="导出族谱PDF">
<FileText className="h-4 w-4" />
</Button>
</div>
@@ -734,10 +841,11 @@ export default function TreePage() {
{/* 关系查询结果对话框 */}
<Dialog open={showRelationDialog} onOpenChange={handleDialogClose}>
<DialogContent className="max-w-2xl" aria-describedby="relation-description">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<p id="relation-description" className="sr-only"></p>
{relationResult && (
<div className="space-y-4">