From 1b01469ee2d1217e6c17ced4c63c2676e1af296c Mon Sep 17 00:00:00 2001 From: qixinbo Date: Tue, 17 Mar 2026 17:23:00 +0800 Subject: [PATCH] fix: data modeling rendering --- frontend/package-lock.json | 191 +++++++++++++++ frontend/package.json | 3 + .../src/components/modeling/TableNode.tsx | 50 ++++ frontend/src/pages/Modeling.tsx | 227 ++++++++++++++---- 4 files changed, 419 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/modeling/TableNode.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb63002..c800e8e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,10 +11,13 @@ "@base-ui/react": "^1.3.0", "@fontsource-variable/geist": "^5.2.8", "@tailwindcss/postcss": "^4.2.1", + "@types/dagre": "^0.7.54", "@types/react-grid-layout": "^1.3.6", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre": "^0.8.5", "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -2964,6 +2967,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -2994,6 +3006,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -3015,6 +3033,31 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", @@ -3460,6 +3503,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", @@ -3934,6 +4037,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4280,6 +4389,19 @@ "node": ">=12" } }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dsv": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", @@ -4468,6 +4590,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4513,6 +4644,51 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -5715,6 +5891,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/graphql": { "version": "16.13.1", "resolved": "https://registry.npmmirror.com/graphql/-/graphql-16.13.1.tgz", @@ -6740,6 +6925,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d2d0bee..b675378 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,10 +13,13 @@ "@base-ui/react": "^1.3.0", "@fontsource-variable/geist": "^5.2.8", "@tailwindcss/postcss": "^4.2.1", + "@types/dagre": "^0.7.54", "@types/react-grid-layout": "^1.3.6", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre": "^0.8.5", "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/frontend/src/components/modeling/TableNode.tsx b/frontend/src/components/modeling/TableNode.tsx new file mode 100644 index 0000000..ea5ea40 --- /dev/null +++ b/frontend/src/components/modeling/TableNode.tsx @@ -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 ( + + + + data.onDetailClick(data.name)} + > +
+ + {data.name} +
+
+ + +
+ {data.columns.map((col) => ( +
+ {col.name} + {/* 类型列已被隐藏 */} +
+ ))} +
+
+ + +
+ ); +}); diff --git a/frontend/src/pages/Modeling.tsx b/frontend/src/pages/Modeling.tsx index 03c3637..db08e8d 100644 --- a/frontend/src/pages/Modeling.tsx +++ b/frontend/src/pages/Modeling.tsx @@ -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[]; } +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(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + 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 = {}; + + 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 = {}; @@ -245,6 +398,9 @@ export function Modeling() { {step === "view" && (
+ @@ -371,56 +527,23 @@ export function Modeling() { - {/* Canvas Area (Simulated) */} -
-
- -
- {mdl?.models.map((model) => ( - openModelDetail(model.name)}> - -
- - {model.name} -
-
- -
- {model.columns.map((col) => ( -
- {col.name} - {col.type} -
- ))} -
- {/* Show Relationships if any */} - {mdl.relationships.filter(r => r.models.includes(model.name)).length > 0 && ( -
-
- Relationships -
- {mdl.relationships - .filter(r => r.models.includes(model.name)) - .map(r => { - const other = r.models.find(m => m !== model.name); - return ( -
- ⟷ {other} -
- ); - }) - } -
- )} -
-
- ))} -
+ {/* Canvas Area (ReactFlow) */} +
+ + + +
)}