Files
SillyTavern_replica/frontend/src/components/Studio/StudioRunNodeGraph.jsx
moranzhi fa6907fb8d feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型
- 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图
- 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行
- Docker 开发配置与文档脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:24:57 +08:00

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;