fix: data modeling rendering

This commit is contained in:
qixinbo
2026-03-17 17:23:00 +08:00
parent 1f74e4299d
commit 1b01469ee2
4 changed files with 419 additions and 52 deletions
@@ -0,0 +1,50 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Table as TableIcon } from "lucide-react";
interface Column {
name: string;
type: string;
}
interface TableNodeData {
name: string;
columns: Column[];
onDetailClick: (name: string) => void;
}
export const TableNode = memo(({ data }: { data: TableNodeData }) => {
return (
<Card className="min-w-[180px] max-w-[240px] shadow-md border-t-4 border-t-blue-500 text-xs">
<Handle type="target" position={Position.Top} className="!bg-blue-500" />
<CardHeader
className="py-2 px-3 bg-gray-50 border-b flex flex-row items-center justify-between cursor-pointer hover:bg-gray-100"
onClick={() => data.onDetailClick(data.name)}
>
<div className="font-semibold flex items-center gap-2 truncate" title={data.name}>
<TableIcon className="w-3 h-3 text-blue-500 shrink-0" />
<span className="truncate">{data.name}</span>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[200px] overflow-y-auto">
{data.columns.map((col) => (
<div
key={col.name}
className="py-1.5 px-3 border-b last:border-0 hover:bg-gray-50 flex items-center"
title={`${col.name} (${col.type})`}
>
<span className="font-medium truncate">{col.name}</span>
{/* 类型列已被隐藏 */}
</div>
))}
</div>
</CardContent>
<Handle type="source" position={Position.Bottom} className="!bg-blue-500" />
</Card>
);
});
+175 -52
View File
@@ -1,5 +1,8 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ReactFlow, Background, Controls, useNodesState, useEdgesState, MarkerType, type Node, type Edge, ConnectionLineType } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "dagre";
import { api } from "../lib/api";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
@@ -7,7 +10,8 @@ import { Label } from "../components/ui/label";
import { ScrollArea } from "../components/ui/scroll-area";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { ArrowLeft, Table as TableIcon, Network } from "lucide-react";
import { ArrowLeft, Table as TableIcon } from "lucide-react";
import { TableNode } from "../components/modeling/TableNode";
interface RawSchema {
[table: string]: { name: string; type: string }[];
@@ -66,6 +70,61 @@ interface ModelDetailResponse {
preview_rows: Record<string, unknown>[];
}
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
// If there are few or no edges, use grid layout to spread out nodes
if (edges.length === 0 || edges.length < nodes.length * 0.3) {
const COLUMNS = 4;
const ROW_HEIGHT = 400; // Height per row including spacing
const COL_WIDTH = 300; // Width per column including spacing
return {
nodes: nodes.map((node, index) => {
const col = index % COLUMNS;
const row = Math.floor(index / COLUMNS);
return {
...node,
position: {
x: col * COL_WIDTH,
y: row * ROW_HEIGHT,
},
};
}),
edges,
};
}
// Otherwise use Dagre for connected graphs
dagreGraph.setGraph({ rankdir: 'TB', nodesep: 100, ranksep: 120 });
nodes.forEach((node) => {
// Estimating height based on column count
const height = 50 + (node.data.columns as Column[]).length * 28;
dagreGraph.setNode(node.id, { width: 240, height });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - 120, // center offset (width/2)
y: nodeWithPosition.y - (nodeWithPosition.height / 2),
},
};
});
return { nodes: layoutedNodes, edges };
};
export function Modeling() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -80,10 +139,104 @@ export function Modeling() {
const [detailLoading, setDetailLoading] = useState(false);
const [modelDetail, setModelDetail] = useState<ModelDetailResponse | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const nodeTypes = useMemo(() => ({ table: TableNode }), []);
// Save layout to localStorage when nodes change (dragged)
const onNodeDragStop = useCallback(() => {
if (nodes.length > 0) {
const layoutData = nodes.map(n => ({ id: n.id, position: n.position }));
localStorage.setItem(`er-layout-${id}`, JSON.stringify(layoutData));
}
}, [nodes, id]);
useEffect(() => {
fetchInitialData();
}, [id]);
useEffect(() => {
if (step === 'view' && mdl) {
// Try to load saved layout
const savedLayoutStr = localStorage.getItem(`er-layout-${id}`);
let savedPositions: Record<string, {x: number, y: number}> = {};
if (savedLayoutStr) {
try {
const parsed = JSON.parse(savedLayoutStr);
if (Array.isArray(parsed)) {
parsed.forEach((item: any) => {
if (item.id && item.position) {
savedPositions[item.id] = item.position;
}
});
}
} catch (e) {
console.error("Failed to parse saved layout", e);
}
}
const initialNodes: Node[] = mdl.models.map((model) => ({
id: model.name,
type: 'table',
position: savedPositions[model.name] || { x: 0, y: 0 },
data: {
name: model.name,
columns: model.columns,
onDetailClick: openModelDetail
},
}));
const initialEdges: Edge[] = mdl.relationships.map((rel, index) => {
// Assuming rel.models has at least 2 elements
if (rel.models.length < 2) return null;
return {
id: `e-${index}`,
source: rel.models[0],
target: rel.models[1],
type: ConnectionLineType.SmoothStep,
animated: false,
label: rel.joinType,
style: { stroke: '#94a3b8' },
labelStyle: { fill: '#64748b', fontSize: 11 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#94a3b8',
},
};
}).filter(Boolean) as Edge[];
// Only run auto-layout if we don't have saved positions for most nodes
// or if user explicitly requests it (future feature)
const hasSavedLayout = Object.keys(savedPositions).length >= initialNodes.length * 0.5;
if (hasSavedLayout) {
setNodes(initialNodes);
setEdges(initialEdges);
} else {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
initialNodes,
initialEdges
);
setNodes(layoutedNodes);
setEdges(layoutedEdges);
}
}
}, [step, mdl, id]);
const handleAutoLayout = () => {
if (!mdl) return;
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
nodes,
edges
);
setNodes([...layoutedNodes]);
setEdges([...layoutedEdges]);
// Clear saved layout to prefer auto layout
localStorage.removeItem(`er-layout-${id}`);
};
const initSelectionFromSchema = (schemaRes: RawSchema) => {
const tableNames = Object.keys(schemaRes);
const columnsMap: Record<string, string[]> = {};
@@ -245,6 +398,9 @@ export function Modeling() {
</div>
{step === "view" && (
<div className="flex gap-2">
<Button variant="outline" onClick={handleAutoLayout}>
Auto Layout
</Button>
<Button variant="outline" onClick={handleReselectTables}>
Reselect Tables
</Button>
@@ -371,56 +527,23 @@ export function Modeling() {
</ScrollArea>
</Card>
{/* Canvas Area (Simulated) */}
<div className="flex-1 overflow-auto bg-slate-100 rounded-lg border p-8 relative">
<div className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#cbd5e1 1px, transparent 1px)',
backgroundSize: '20px 20px'
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 relative z-10">
{mdl?.models.map((model) => (
<Card key={model.name} className="shadow-md border-t-4 border-t-blue-500 min-w-[240px] cursor-pointer" onClick={() => openModelDetail(model.name)}>
<CardHeader className="py-3 px-4 bg-gray-50 border-b flex flex-row items-center justify-between">
<div className="font-semibold text-sm flex items-center gap-2">
<TableIcon className="w-4 h-4 text-blue-500" />
{model.name}
</div>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[300px] overflow-y-auto text-xs">
{model.columns.map((col) => (
<div key={col.name} className="flex justify-between py-2 px-4 border-b last:border-0 hover:bg-gray-50">
<span className="font-medium">{col.name}</span>
<span className="text-muted-foreground font-mono text-[10px]">{col.type}</span>
</div>
))}
</div>
{/* Show Relationships if any */}
{mdl.relationships.filter(r => r.models.includes(model.name)).length > 0 && (
<div className="bg-orange-50 p-2 border-t text-xs">
<div className="font-semibold text-orange-700 mb-1 flex items-center gap-1">
<Network className="w-3 h-3" /> Relationships
</div>
{mdl.relationships
.filter(r => r.models.includes(model.name))
.map(r => {
const other = r.models.find(m => m !== model.name);
return (
<div key={r.name} className="text-orange-600 truncate" title={`${r.joinType} with ${other}`}>
{other}
</div>
);
})
}
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Canvas Area (ReactFlow) */}
<div className="flex-1 overflow-hidden bg-slate-50 rounded-lg border relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
fitView
minZoom={0.1}
maxZoom={1.5}
attributionPosition="bottom-right"
>
<Background color="#cbd5e1" gap={20} size={1} />
<Controls />
</ReactFlow>
</div>
</div>
)}