Files
shensheshou/app/data-ingestion/lineage/page.tsx

446 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}