chore: 以本地为准,上传全部并替换 GitHub
This commit is contained in:
@@ -1,269 +1,444 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
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 { Input } from "@/components/ui/input"
|
||||
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,
|
||||
Search,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Eye,
|
||||
MousePointer2,
|
||||
Move,
|
||||
Link2,
|
||||
Unlink,
|
||||
CheckCircle2,
|
||||
Settings,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Database,
|
||||
Table,
|
||||
ArrowRight,
|
||||
Filter,
|
||||
Download,
|
||||
} from "lucide-react"
|
||||
|
||||
// 第二部分:数据接入 - 数据血缘
|
||||
export default function DataLineagePage() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
// 节点类型
|
||||
interface LineageNode {
|
||||
id: string
|
||||
type: 'source' | 'transform' | 'target'
|
||||
name: string
|
||||
database?: string
|
||||
collection?: string
|
||||
fields: string[]
|
||||
x: number
|
||||
y: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// 数据血缘节点
|
||||
const lineageNodes = [
|
||||
// 数据源层
|
||||
{ id: "source-1", type: "source", name: "存客宝-MySQL", level: 0, x: 50, y: 100 },
|
||||
{ id: "source-2", type: "source", name: "触客宝-MySQL", level: 0, x: 50, y: 200 },
|
||||
{ id: "source-3", type: "source", name: "数智员工-API", level: 0, x: 50, y: 300 },
|
||||
// 原始表层
|
||||
{ id: "raw-1", type: "table", name: "raw_users", level: 1, x: 250, y: 100 },
|
||||
{ id: "raw-2", type: "table", name: "raw_transactions", level: 1, x: 250, y: 200 },
|
||||
{ id: "raw-3", type: "table", name: "raw_behaviors", level: 1, x: 250, y: 300 },
|
||||
// 清洗层
|
||||
{ id: "clean-1", type: "table", name: "clean_users", level: 2, x: 450, y: 150 },
|
||||
{ id: "clean-2", type: "table", name: "clean_transactions", level: 2, x: 450, y: 250 },
|
||||
// 标签层
|
||||
{ id: "tag-1", type: "table", name: "user_tags", level: 3, x: 650, y: 150 },
|
||||
{ id: "tag-2", type: "table", name: "user_portraits", level: 3, x: 650, y: 250 },
|
||||
// 输出层
|
||||
{ id: "output-1", type: "output", name: "流量包-高价值用户", level: 4, x: 850, y: 150 },
|
||||
{ id: "output-2", type: "output", name: "API-用户画像", level: 4, x: 850, y: 250 },
|
||||
]
|
||||
// 连接类型
|
||||
interface LineageConnection {
|
||||
id: string
|
||||
sourceNode: string
|
||||
sourceField: string
|
||||
targetNode: string
|
||||
targetField: string
|
||||
}
|
||||
|
||||
// 血缘关系
|
||||
const lineageEdges = [
|
||||
{ from: "source-1", to: "raw-1" },
|
||||
{ from: "source-1", to: "raw-2" },
|
||||
{ from: "source-2", to: "raw-3" },
|
||||
{ from: "source-3", to: "raw-1" },
|
||||
{ from: "raw-1", to: "clean-1" },
|
||||
{ from: "raw-2", to: "clean-2" },
|
||||
{ from: "raw-3", to: "clean-1" },
|
||||
{ from: "clean-1", to: "tag-1" },
|
||||
{ from: "clean-2", to: "tag-1" },
|
||||
{ from: "clean-1", to: "tag-2" },
|
||||
{ from: "tag-1", to: "output-1" },
|
||||
{ from: "tag-2", to: "output-2" },
|
||||
]
|
||||
// 节点和连接从API动态加载
|
||||
|
||||
const getNodeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "source":
|
||||
return "bg-blue-100 border-blue-300 text-blue-700"
|
||||
case "table":
|
||||
return "bg-green-100 border-green-300 text-green-700"
|
||||
case "output":
|
||||
return "bg-purple-100 border-purple-300 text-purple-700"
|
||||
default:
|
||||
return "bg-gray-100 border-gray-300 text-gray-700"
|
||||
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 getNodeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "source":
|
||||
return <Database className="h-4 w-4" />
|
||||
case "table":
|
||||
return <Table className="h-4 w-4" />
|
||||
case "output":
|
||||
return <ArrowRight className="h-4 w-4" />
|
||||
default:
|
||||
return <GitBranch className="h-4 w-4" />
|
||||
}
|
||||
// 渲染连接线
|
||||
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-6">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-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>
|
||||
<p className="text-sm text-gray-500 mt-1">可视化数据流向,拖拽节点,点击字段创建关联</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索表名或数据源..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 w-64 bg-white"
|
||||
/>
|
||||
{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">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图例 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-blue-100 border border-blue-300" />
|
||||
<span className="text-sm text-gray-600">数据源</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-green-100 border border-green-300" />
|
||||
<span className="text-sm text-gray-600">数据表</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-purple-100 border border-purple-300" />
|
||||
<span className="text-sm text-gray-600">输出服务</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 血缘图 */}
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">血缘关系图</CardTitle>
|
||||
{/* 操作提示 */}
|
||||
<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">
|
||||
<Button variant="outline" size="icon">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<MousePointer2 className="h-4 w-4 text-purple-500" />
|
||||
<span>拖拽节点移动位置</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative h-[500px] bg-gray-50 rounded-lg overflow-hidden">
|
||||
{/* SVG连线 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none">
|
||||
{lineageEdges.map((edge, index) => {
|
||||
const fromNode = lineageNodes.find((n) => n.id === edge.from)
|
||||
const toNode = lineageNodes.find((n) => n.id === edge.to)
|
||||
if (!fromNode || !toNode) return null
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={fromNode.x + 80}
|
||||
y1={fromNode.y + 20}
|
||||
x2={toNode.x}
|
||||
y2={toNode.y + 20}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* 节点 */}
|
||||
{lineageNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`absolute px-3 py-2 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md ${getNodeColor(node.type)} ${
|
||||
selectedNode === node.id ? "ring-2 ring-blue-500 ring-offset-2" : ""
|
||||
}`}
|
||||
style={{ left: node.x, top: node.y }}
|
||||
onClick={() => setSelectedNode(node.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getNodeIcon(node.type)}
|
||||
<span className="text-sm font-medium whitespace-nowrap">{node.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 层级标签 */}
|
||||
{[
|
||||
{ label: "数据源", x: 50 },
|
||||
{ label: "原始层", x: 250 },
|
||||
{ label: "清洗层", x: 450 },
|
||||
{ label: "标签层", x: 650 },
|
||||
{ label: "输出层", x: 850 },
|
||||
].map((level, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute top-2 text-xs text-gray-400 font-medium"
|
||||
style={{ left: level.x }}
|
||||
>
|
||||
{level.label}
|
||||
</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>
|
||||
|
||||
{/* 节点详情 */}
|
||||
{selectedNode && (
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
节点详情 - {lineageNodes.find((n) => n.id === selectedNode)?.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">上游依赖</h4>
|
||||
{/* 图例 */}
|
||||
<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">
|
||||
{lineageEdges
|
||||
.filter((e) => e.to === selectedNode)
|
||||
.map((edge, index) => (
|
||||
<Badge key={index} variant="secondary" className="mr-2">
|
||||
{lineageNodes.find((n) => n.id === edge.from)?.name}
|
||||
</Badge>
|
||||
))}
|
||||
{lineageEdges.filter((e) => e.to === selectedNode).length === 0 && (
|
||||
<span className="text-sm text-gray-400">无上游依赖</span>
|
||||
)}
|
||||
<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>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">下游影响</h4>
|
||||
<div className="space-y-2">
|
||||
{lineageEdges
|
||||
.filter((e) => e.from === selectedNode)
|
||||
.map((edge, index) => (
|
||||
<Badge key={index} variant="secondary" className="mr-2">
|
||||
{lineageNodes.find((n) => n.id === edge.to)?.name}
|
||||
</Badge>
|
||||
))}
|
||||
{lineageEdges.filter((e) => e.from === selectedNode).length === 0 && (
|
||||
<span className="text-sm text-gray-400">无下游影响</span>
|
||||
)}
|
||||
<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>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">更新信息</h4>
|
||||
<p className="text-sm text-gray-700">最后更新:2分钟前</p>
|
||||
<p className="text-sm text-gray-700">记录数:1,234,567</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNodeDetail(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user