- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
7.0 KiB
JavaScript
234 lines
7.0 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
|
|
import { buildGraphLayout } from './edit/variableUtils';
|
|
|
|
|
|
|
|
import './StudioRunNodeGraph.css';
|
|
|
|
|
|
|
|
const NODE_STATUS_LABEL = {
|
|
|
|
pending: '待执行',
|
|
|
|
active: '进行中',
|
|
|
|
completed: '已完成',
|
|
|
|
skipped: '已跳过',
|
|
|
|
};
|
|
|
|
|
|
|
|
function defaultEdgePath(from, to, nodeW, nodeH) {
|
|
|
|
const x1 = from.x + nodeW / 2;
|
|
|
|
const y1 = from.y + nodeH;
|
|
|
|
const x2 = to.x + nodeW / 2;
|
|
|
|
const y2 = to.y;
|
|
|
|
const dy = Math.max(24, (y2 - y1) * 0.45);
|
|
|
|
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function StudioRunNodeGraph({
|
|
|
|
pipeline,
|
|
|
|
nodeStates,
|
|
|
|
currentNodeId,
|
|
|
|
variant = 'sidebar',
|
|
|
|
}) {
|
|
|
|
const containerRef = useRef(null);
|
|
|
|
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
|
|
|
const [dragging, setDragging] = useState(null);
|
|
|
|
const [selectedId, setSelectedId] = useState(currentNodeId);
|
|
|
|
const [nodeOverrides, setNodeOverrides] = useState({});
|
|
|
|
|
|
|
|
const isCenter = variant === 'center';
|
|
|
|
|
|
|
|
const layout = useMemo(
|
|
|
|
() => buildGraphLayout(pipeline, nodeStates, { vertical: true }),
|
|
|
|
[pipeline, nodeStates]
|
|
|
|
);
|
|
|
|
|
|
|
|
const { nodeW, nodeH } = layout;
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setSelectedId(currentNodeId);
|
|
|
|
}, [currentNodeId]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const el = containerRef.current;
|
|
|
|
if (!el || !layout.width) return;
|
|
|
|
const cw = el.clientWidth || 200;
|
|
|
|
const ch = el.clientHeight || 160;
|
|
|
|
const scale = Math.min(1, (cw - 16) / layout.width, (ch - 16) / layout.height);
|
|
|
|
setView({
|
|
|
|
x: (cw - layout.width * scale) / 2,
|
|
|
|
y: Math.max(8, (ch - layout.height * scale) / 2),
|
|
|
|
scale: Math.max(0.45, scale),
|
|
|
|
});
|
|
|
|
}, [layout.width, layout.height, pipeline, variant]);
|
|
|
|
|
|
|
|
const nodeMap = useMemo(
|
|
|
|
() => Object.fromEntries(layout.nodes.map((n) => [n.id, n])),
|
|
|
|
[layout.nodes]
|
|
|
|
);
|
|
|
|
|
|
|
|
const handleWheel = useCallback((e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
|
|
|
setView((v) => ({
|
|
|
|
...v,
|
|
|
|
scale: Math.min(2.5, Math.max(0.35, v.scale * delta)),
|
|
|
|
}));
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const handleBgMouseDown = useCallback(
|
|
|
|
(e) => {
|
|
|
|
if (e.button !== 0) return;
|
|
|
|
e.preventDefault();
|
|
|
|
setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, origin: { ...view } });
|
|
|
|
},
|
|
|
|
[view]
|
|
|
|
);
|
|
|
|
|
|
|
|
const handleNodeMouseDown = useCallback(
|
|
|
|
(e, nodeId) => {
|
|
|
|
if (e.button !== 0) return;
|
|
|
|
e.stopPropagation();
|
|
|
|
setSelectedId(nodeId);
|
|
|
|
const node = nodeMap[nodeId];
|
|
|
|
if (!node) return;
|
|
|
|
setDragging({
|
|
|
|
type: 'node',
|
|
|
|
nodeId,
|
|
|
|
startX: e.clientX,
|
|
|
|
startY: e.clientY,
|
|
|
|
origin: { x: node.x, y: node.y },
|
|
|
|
});
|
|
|
|
},
|
|
|
|
[nodeMap]
|
|
|
|
);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!dragging) return undefined;
|
|
|
|
|
|
|
|
const onMove = (e) => {
|
|
|
|
if (dragging.type === 'pan') {
|
|
|
|
setView((v) => ({
|
|
|
|
...v,
|
|
|
|
x: dragging.origin.x + (e.clientX - dragging.startX),
|
|
|
|
y: dragging.origin.y + (e.clientY - dragging.startY),
|
|
|
|
}));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (dragging.type === 'node') {
|
|
|
|
const dx = (e.clientX - dragging.startX) / view.scale;
|
|
|
|
const dy = (e.clientY - dragging.startY) / view.scale;
|
|
|
|
setNodeOverrides((prev) => ({
|
|
|
|
...prev,
|
|
|
|
[dragging.nodeId]: {
|
|
|
|
x: dragging.origin.x + dx,
|
|
|
|
y: dragging.origin.y + dy,
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const onUp = () => setDragging(null);
|
|
|
|
|
|
|
|
window.addEventListener('mousemove', onMove);
|
|
|
|
window.addEventListener('mouseup', onUp);
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener('mousemove', onMove);
|
|
|
|
window.removeEventListener('mouseup', onUp);
|
|
|
|
};
|
|
|
|
}, [dragging, view.scale]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setNodeOverrides({});
|
|
|
|
}, [pipeline, nodeStates]);
|
|
|
|
|
|
|
|
const positionedNodes = layout.nodes.map((n) => ({
|
|
|
|
...n,
|
|
|
|
...(nodeOverrides[n.id] || {}),
|
|
|
|
}));
|
|
|
|
|
|
|
|
const posMap = Object.fromEntries(positionedNodes.map((n) => [n.id, n]));
|
|
|
|
|
|
|
|
const pathFn = layout.edgePath || defaultEdgePath;
|
|
|
|
|
|
|
|
if (!layout.nodes.length) {
|
|
|
|
return <div className="studio-run-graph-empty">暂无节点</div>;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
ref={containerRef}
|
|
|
|
className={`studio-run-graph${isCenter ? ' studio-run-graph--center' : ''}`}
|
|
|
|
onWheel={handleWheel}
|
|
|
|
onMouseDown={handleBgMouseDown}
|
|
|
|
>
|
|
|
|
<svg
|
|
|
|
className="studio-run-graph__svg"
|
|
|
|
width="100%"
|
|
|
|
height="100%"
|
|
|
|
aria-label="节点依赖进度图"
|
|
|
|
>
|
|
|
|
<g transform={`translate(${view.x},${view.y}) scale(${view.scale})`}>
|
|
|
|
<defs>
|
|
|
|
<marker
|
|
|
|
id="studio-run-graph-arrow"
|
|
|
|
markerWidth="8"
|
|
|
|
markerHeight="8"
|
|
|
|
refX="4"
|
|
|
|
refY="4"
|
|
|
|
orient="auto"
|
|
|
|
>
|
|
|
|
<path d="M0,0 L0,8 L8,4 z" fill="var(--color-text-muted)" />
|
|
|
|
</marker>
|
|
|
|
</defs>
|
|
|
|
{layout.edges.map(({ from, to }) => {
|
|
|
|
const a = posMap[from];
|
|
|
|
const b = posMap[to];
|
|
|
|
if (!a || !b) return null;
|
|
|
|
return (
|
|
|
|
<path
|
|
|
|
key={`${from}-${to}`}
|
|
|
|
className="studio-run-graph__edge"
|
|
|
|
d={pathFn(a, b, nodeW, nodeH)}
|
|
|
|
markerEnd="url(#studio-run-graph-arrow)"
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
{positionedNodes.map((node) => (
|
|
|
|
<g
|
|
|
|
key={node.id}
|
|
|
|
transform={`translate(${node.x},${node.y})`}
|
|
|
|
className={`studio-run-graph__node status-${node.status}${selectedId === node.id ? ' is-selected' : ''}${currentNodeId === node.id ? ' is-current' : ''}`}
|
|
|
|
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
|
|
|
>
|
|
|
|
<rect
|
|
|
|
className="studio-run-graph__node-bg"
|
|
|
|
width={nodeW}
|
|
|
|
height={nodeH}
|
|
|
|
rx="8"
|
|
|
|
/>
|
|
|
|
<text
|
|
|
|
className="studio-run-graph__node-label"
|
|
|
|
x={nodeW / 2}
|
|
|
|
y={nodeH / 2 - 6}
|
|
|
|
textAnchor="middle"
|
|
|
|
>
|
|
|
|
{node.label.length > 8 ? `${node.label.slice(0, 7)}…` : node.label}
|
|
|
|
</text>
|
|
|
|
<text
|
|
|
|
className="studio-run-graph__node-status"
|
|
|
|
x={nodeW / 2}
|
|
|
|
y={nodeH / 2 + 12}
|
|
|
|
textAnchor="middle"
|
|
|
|
>
|
|
|
|
{NODE_STATUS_LABEL[node.status] || node.status}
|
|
|
|
</text>
|
|
|
|
</g>
|
|
|
|
))}
|
|
|
|
</g>
|
|
|
|
</svg>
|
|
|
|
<div className="studio-run-graph__hint">
|
|
|
|
依赖关系树 · 滚轮缩放 · 拖拽平移
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export default StudioRunNodeGraph;
|
|
|