0.7.0.0
This commit is contained in:
+163
-55
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user