446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useRef, useEffect, useCallback } from "react"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog"
|
||
import { Label } from "@/components/ui/label"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import {
|
||
GitBranch,
|
||
Database,
|
||
ArrowRight,
|
||
Zap,
|
||
RefreshCw,
|
||
Plus,
|
||
Eye,
|
||
MousePointer2,
|
||
Move,
|
||
Link2,
|
||
Unlink,
|
||
CheckCircle2,
|
||
Settings,
|
||
ZoomIn,
|
||
ZoomOut,
|
||
Maximize2,
|
||
} from "lucide-react"
|
||
|
||
// 节点类型
|
||
interface LineageNode {
|
||
id: string
|
||
type: 'source' | 'transform' | 'target'
|
||
name: string
|
||
database?: string
|
||
collection?: string
|
||
fields: string[]
|
||
x: number
|
||
y: number
|
||
color: string
|
||
}
|
||
|
||
// 连接类型
|
||
interface LineageConnection {
|
||
id: string
|
||
sourceNode: string
|
||
sourceField: string
|
||
targetNode: string
|
||
targetField: string
|
||
}
|
||
|
||
// 节点和连接从API动态加载
|
||
|
||
export default function LineagePage() {
|
||
const [nodes, setNodes] = useState<LineageNode[]>([])
|
||
const [connections, setConnections] = useState<LineageConnection[]>([])
|
||
const [selectedNode, setSelectedNode] = useState<LineageNode | null>(null)
|
||
const [draggingNode, setDraggingNode] = useState<string | null>(null)
|
||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||
const [connectingFrom, setConnectingFrom] = useState<{ nodeId: string; field: string } | null>(null)
|
||
const [showNodeDetail, setShowNodeDetail] = useState(false)
|
||
const [zoom, setZoom] = useState(0.8)
|
||
const [loading, setLoading] = useState(true)
|
||
const canvasRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 加载真实数据库结构
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
fetch('/api/database-structure?action=lineage')
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
setNodes(data.nodes || [])
|
||
setConnections(data.connections || [])
|
||
}
|
||
})
|
||
.catch(console.error)
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
// 节点拖动开始
|
||
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
||
e.stopPropagation()
|
||
const node = nodes.find(n => n.id === nodeId)
|
||
if (!node) return
|
||
|
||
setDraggingNode(nodeId)
|
||
setDragOffset({
|
||
x: e.clientX - node.x * zoom,
|
||
y: e.clientY - node.y * zoom,
|
||
})
|
||
}
|
||
|
||
// 节点拖动
|
||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||
if (!draggingNode || !canvasRef.current) return
|
||
|
||
const rect = canvasRef.current.getBoundingClientRect()
|
||
const newX = (e.clientX - rect.left - dragOffset.x + rect.left) / zoom
|
||
const newY = (e.clientY - rect.top - dragOffset.y + rect.top) / zoom
|
||
|
||
setNodes(prev => prev.map(node =>
|
||
node.id === draggingNode
|
||
? { ...node, x: Math.max(0, newX), y: Math.max(0, newY) }
|
||
: node
|
||
))
|
||
}, [draggingNode, dragOffset, zoom])
|
||
|
||
// 节点拖动结束
|
||
const handleMouseUp = useCallback(() => {
|
||
setDraggingNode(null)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (draggingNode) {
|
||
window.addEventListener('mousemove', handleMouseMove)
|
||
window.addEventListener('mouseup', handleMouseUp)
|
||
return () => {
|
||
window.removeEventListener('mousemove', handleMouseMove)
|
||
window.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
}
|
||
}, [draggingNode, handleMouseMove, handleMouseUp])
|
||
|
||
// 开始连接字段
|
||
const startConnecting = (nodeId: string, field: string) => {
|
||
setConnectingFrom({ nodeId, field })
|
||
}
|
||
|
||
// 完成连接
|
||
const finishConnecting = (nodeId: string, field: string) => {
|
||
if (!connectingFrom || connectingFrom.nodeId === nodeId) {
|
||
setConnectingFrom(null)
|
||
return
|
||
}
|
||
|
||
// 创建新连接
|
||
const newConnection: LineageConnection = {
|
||
id: `conn_${Date.now()}`,
|
||
sourceNode: connectingFrom.nodeId,
|
||
sourceField: connectingFrom.field,
|
||
targetNode: nodeId,
|
||
targetField: field,
|
||
}
|
||
|
||
setConnections(prev => [...prev, newConnection])
|
||
setConnectingFrom(null)
|
||
}
|
||
|
||
// 删除连接
|
||
const deleteConnection = (connId: string) => {
|
||
setConnections(prev => prev.filter(c => c.id !== connId))
|
||
}
|
||
|
||
// 获取节点位置
|
||
const getNodeFieldPosition = (nodeId: string, field: string, isSource: boolean) => {
|
||
const node = nodes.find(n => n.id === nodeId)
|
||
if (!node) return { x: 0, y: 0 }
|
||
|
||
const fieldIndex = node.fields.indexOf(field)
|
||
const nodeWidth = 200
|
||
const headerHeight = 40
|
||
const fieldHeight = 28
|
||
|
||
return {
|
||
x: isSource ? node.x + nodeWidth : node.x,
|
||
y: node.y + headerHeight + fieldIndex * fieldHeight + fieldHeight / 2,
|
||
}
|
||
}
|
||
|
||
// 渲染连接线
|
||
const renderConnections = () => {
|
||
return connections.map(conn => {
|
||
const source = getNodeFieldPosition(conn.sourceNode, conn.sourceField, true)
|
||
const target = getNodeFieldPosition(conn.targetNode, conn.targetField, false)
|
||
|
||
// 贝塞尔曲线
|
||
const midX = (source.x + target.x) / 2
|
||
const path = `M ${source.x} ${source.y} C ${midX} ${source.y}, ${midX} ${target.y}, ${target.x} ${target.y}`
|
||
|
||
return (
|
||
<g key={conn.id} className="group cursor-pointer" onClick={() => deleteConnection(conn.id)}>
|
||
<path
|
||
d={path}
|
||
fill="none"
|
||
stroke="#a78bfa"
|
||
strokeWidth="2"
|
||
className="group-hover:stroke-red-500 transition-colors"
|
||
/>
|
||
<circle cx={source.x} cy={source.y} r="4" fill="#a78bfa" className="group-hover:fill-red-500" />
|
||
<circle cx={target.x} cy={target.y} r="4" fill="#a78bfa" className="group-hover:fill-red-500" />
|
||
</g>
|
||
)
|
||
})
|
||
}
|
||
|
||
// 渲染节点
|
||
const renderNode = (node: LineageNode) => {
|
||
const isConnecting = connectingFrom !== null
|
||
const isSource = connectingFrom?.nodeId === node.id
|
||
|
||
return (
|
||
<div
|
||
key={node.id}
|
||
className={`absolute bg-white rounded-xl shadow-lg border-2 w-[200px] transition-shadow ${
|
||
draggingNode === node.id ? 'shadow-2xl ring-2 ring-purple-500 cursor-grabbing' : 'cursor-grab'
|
||
} ${selectedNode?.id === node.id ? 'ring-2 ring-blue-500' : ''}`}
|
||
style={{
|
||
left: node.x,
|
||
top: node.y,
|
||
borderColor: isSource ? '#ef4444' : '#e5e7eb',
|
||
}}
|
||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||
onClick={() => {
|
||
setSelectedNode(node)
|
||
setShowNodeDetail(true)
|
||
}}
|
||
>
|
||
{/* 节点头部 */}
|
||
<div className={`px-3 py-2 rounded-t-lg bg-gradient-to-r ${node.color} flex items-center justify-between`}>
|
||
<div className="flex items-center gap-2">
|
||
{node.type === 'source' && <Database className="h-4 w-4 text-white" />}
|
||
{node.type === 'transform' && <Zap className="h-4 w-4 text-white" />}
|
||
{node.type === 'target' && <GitBranch className="h-4 w-4 text-white" />}
|
||
<span className="text-white text-sm font-medium truncate">{node.name}</span>
|
||
</div>
|
||
<Move className="h-3 w-3 text-white/70" />
|
||
</div>
|
||
|
||
{/* 字段列表 */}
|
||
<div className="p-1">
|
||
{node.fields.map((field, i) => (
|
||
<div
|
||
key={field}
|
||
className={`px-2 py-1 text-xs rounded flex items-center justify-between hover:bg-gray-100 ${
|
||
isConnecting && !isSource ? 'cursor-crosshair hover:bg-purple-100' : ''
|
||
} ${connectingFrom?.field === field && isSource ? 'bg-red-100' : ''}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (isConnecting && !isSource) {
|
||
finishConnecting(node.id, field)
|
||
} else if (!isConnecting) {
|
||
startConnecting(node.id, field)
|
||
}
|
||
}}
|
||
>
|
||
<span className="font-mono text-gray-700">{field}</span>
|
||
<div className={`w-3 h-3 rounded-full border-2 ${
|
||
connectingFrom?.field === field && isSource
|
||
? 'bg-red-500 border-red-600'
|
||
: 'border-gray-300 hover:border-purple-500 hover:bg-purple-100'
|
||
}`} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 数据库信息 */}
|
||
{node.database && (
|
||
<div className="px-2 py-1 text-xs text-gray-400 border-t">
|
||
{node.database}.{node.collection}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||
<div className="p-6 space-y-4">
|
||
{/* 顶部标题 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">数据血缘</h1>
|
||
<p className="text-sm text-gray-500 mt-1">可视化数据流向,拖拽节点,点击字段创建关联</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{loading && (
|
||
<Badge className="bg-yellow-100 text-yellow-700 animate-pulse">
|
||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||
加载真实数据库...
|
||
</Badge>
|
||
)}
|
||
<div className="flex items-center gap-1 bg-white rounded-lg shadow-sm p-1">
|
||
<Button variant="ghost" size="sm" onClick={() => setZoom(z => Math.max(0.5, z - 0.1))}>
|
||
<ZoomOut className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-sm px-2">{Math.round(zoom * 100)}%</span>
|
||
<Button variant="ghost" size="sm" onClick={() => setZoom(z => Math.min(1.5, z + 0.1))}>
|
||
<ZoomIn className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => setZoom(0.8)}>
|
||
<Maximize2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||
<RefreshCw className="h-4 w-4 mr-2" />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作提示 */}
|
||
<Card className="border-0 shadow-sm bg-gradient-to-r from-purple-50 to-blue-50">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-6 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<MousePointer2 className="h-4 w-4 text-purple-500" />
|
||
<span>拖拽节点移动位置</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Link2 className="h-4 w-4 text-blue-500" />
|
||
<span>点击字段圆点创建连接</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Unlink className="h-4 w-4 text-red-500" />
|
||
<span>点击连线删除关联</span>
|
||
</div>
|
||
{connectingFrom && (
|
||
<Badge className="bg-red-100 text-red-700 animate-pulse">
|
||
正在连接: {connectingFrom.field} → 点击目标字段完成
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 图例 */}
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-4 rounded bg-gradient-to-r from-blue-400 to-blue-600" />
|
||
<span className="text-sm text-gray-600">数据源</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-4 rounded bg-gradient-to-r from-purple-400 to-purple-600" />
|
||
<span className="text-sm text-gray-600">转换层</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-4 h-4 rounded bg-gradient-to-r from-emerald-400 to-emerald-600" />
|
||
<span className="text-sm text-gray-600">目标表</span>
|
||
</div>
|
||
<div className="ml-auto text-sm text-gray-500">
|
||
节点: {nodes.length} | 连接: {connections.length}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 画布 */}
|
||
<Card className="border-0 shadow-lg bg-white/80 overflow-hidden">
|
||
<CardContent className="p-0">
|
||
<div
|
||
ref={canvasRef}
|
||
className="relative bg-[linear-gradient(#e5e7eb_1px,transparent_1px),linear-gradient(90deg,#e5e7eb_1px,transparent_1px)] bg-[size:20px_20px]"
|
||
style={{
|
||
height: '650px',
|
||
transform: `scale(${zoom})`,
|
||
transformOrigin: 'top left',
|
||
width: `${100/zoom}%`,
|
||
}}
|
||
>
|
||
{/* SVG连接线层 */}
|
||
<svg
|
||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||
style={{ zIndex: 1 }}
|
||
>
|
||
<g className="pointer-events-auto">
|
||
{renderConnections()}
|
||
</g>
|
||
</svg>
|
||
|
||
{/* 节点层 */}
|
||
<div className="absolute inset-0" style={{ zIndex: 2 }}>
|
||
{nodes.map(renderNode)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 节点详情弹窗 */}
|
||
<Dialog open={showNodeDetail} onOpenChange={setShowNodeDetail}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{selectedNode?.name}</DialogTitle>
|
||
<DialogDescription>
|
||
{selectedNode?.type === 'source' && '数据源节点'}
|
||
{selectedNode?.type === 'transform' && '转换处理节点'}
|
||
{selectedNode?.type === 'target' && '目标输出节点'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{selectedNode && (
|
||
<div className="space-y-4 py-4">
|
||
{selectedNode.database && (
|
||
<div className="space-y-2">
|
||
<Label>数据库</Label>
|
||
<div className="p-2 rounded bg-gray-100 font-mono text-sm">
|
||
{selectedNode.database}.{selectedNode.collection}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label>字段列表 ({selectedNode.fields.length})</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedNode.fields.map(field => (
|
||
<Badge key={field} variant="outline" className="font-mono">{field}</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>关联连接</Label>
|
||
<div className="space-y-1">
|
||
{connections.filter(c => c.sourceNode === selectedNode.id || c.targetNode === selectedNode.id).map(conn => {
|
||
const isSource = conn.sourceNode === selectedNode.id
|
||
const otherNode = nodes.find(n => n.id === (isSource ? conn.targetNode : conn.sourceNode))
|
||
return (
|
||
<div key={conn.id} className="flex items-center gap-2 text-sm p-2 rounded bg-gray-50">
|
||
<Badge variant="outline" className="font-mono">{isSource ? conn.sourceField : conn.targetField}</Badge>
|
||
<ArrowRight className="h-3 w-3" />
|
||
<span className="text-gray-500">{otherNode?.name}</span>
|
||
<Badge variant="outline" className="font-mono">{isSource ? conn.targetField : conn.sourceField}</Badge>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowNodeDetail(false)}>关闭</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|