ReactFlow前端拖拽模型与参数配置功能MVP

This commit is contained in:
ljz committed 2026-07-11 10:49:11 +08:00
1 parent 70c91ed019
commit d3b99304fb
2001 files changed
+349044 -548

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>System Simulation Flow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1605
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "system-simulation-flow",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@xyflow/react": "^12.11.2",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"typescript": "^7.0.2",
"vite": "^8.1.4"
}
}
+817
View File
@@ -0,0 +1,817 @@
import { DragEvent, useCallback, useMemo, useState } from "react";
import {
Background,
Controls,
Handle,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
SelectionMode,
addEdge,
type Connection,
type Edge,
type Node,
type NodeProps,
type NodeTypes,
useEdgesState,
useNodesState,
useReactFlow,
} from "@xyflow/react";
type ParameterValue = number | string;
type ParameterDefinition = {
label: string;
unit?: string;
default: ParameterValue;
};
type ComponentDefinition = {
type: string;
label: string;
modelType: string;
ports: string[];
parameters: Record<string, ParameterDefinition>;
};
type SimulationNodeData = {
label: string;
componentType: string;
modelType: string;
ports: string[];
parameters: Record<string, ParameterValue>;
};
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
type SimulationEdge = Edge;
type SimulationConfig = {
t_start: number;
t_stop: number;
step: number;
max_step: number;
method: string;
};
type ProjectNodePayload = {
id: string;
type: "simulationComponent";
position: {
x: number;
y: number;
};
data: SimulationNodeData;
};
type ProjectEdgePayload = {
id: string;
source: string;
target: string;
sourceHandle: string | null;
targetHandle: string | null;
};
type ReactFlowProjectPayload = {
name: string;
nodes: ProjectNodePayload[];
edges: ProjectEdgePayload[];
simulation: SimulationConfig;
};
type SaveProjectResult = {
id: string;
componentCount: number;
connectionCount: number;
};
type SimulationResult = {
success: boolean;
message: string;
final: {
time: number;
tankPressure: number;
tankTemperature: number;
cylinderPressure: number;
cylinderTemperature: number;
};
series: Record<string, number[]>;
artifacts: Record<string, string>;
networkSummary: string;
};
const defaultSimulationConfig: SimulationConfig = {
t_start: 0,
t_stop: 2,
step: 0.1,
max_step: 0.005,
method: "BDF",
};
const componentDefinitions: ComponentDefinition[] = [
{
type: "cylinder",
label: "气瓶",
modelType: "cylinder",
ports: ["port_b"],
parameters: {
volume: { label: "容积", unit: "m3", default: 0.01 },
p0: { label: "初始压力", unit: "Pa", default: 35000000 },
T0: { label: "初始温度", unit: "K", default: 300 },
},
},
{
type: "tank",
label: "贮箱",
modelType: "tank",
ports: ["port_a"],
parameters: {
volume: { label: "容积", unit: "m3", default: 0.1 },
p0: { label: "初始压力", unit: "Pa", default: 100000 },
T0: { label: "初始温度", unit: "K", default: 300 },
},
},
{
type: "pipe",
label: "管段",
modelType: "pipe",
ports: ["port_a", "port_b"],
parameters: {
length: { label: "长度", unit: "m", default: 5 },
diameter: { label: "直径", unit: "m", default: 0.02 },
lambda_darcy: { label: "摩阻系数", default: 0.02 },
p0: { label: "初始压力", unit: "Pa", default: 100000 },
T0: { label: "初始温度", unit: "K", default: 300 },
},
},
{
type: "orifice",
label: "孔板/阀门",
modelType: "orifice",
ports: ["port_a", "port_b"],
parameters: {
K: { label: "流量系数", default: 0.00001 },
opening: { label: "开度", default: 1 },
},
},
{
type: "tee",
label: "三通",
modelType: "tee",
ports: ["port_in", "port_out1", "port_out2"],
parameters: {},
},
];
const nextNodeNumberByType: Record<string, number> = {};
function defaultParameters(definition: ComponentDefinition) {
return Object.fromEntries(
Object.entries(definition.parameters).map(([key, parameter]) => [
key,
parameter.default,
]),
);
}
function buildNodeData(definition: ComponentDefinition): SimulationNodeData {
return {
label: definition.label,
componentType: definition.type,
modelType: definition.modelType,
ports: definition.ports,
parameters: defaultParameters(definition),
};
}
function SimulationComponentNode({ data, selected }: NodeProps<SimulationNode>) {
const inputPorts = data.ports.filter((port) => port.includes("in") || port === "port_a");
const outputPorts = data.ports.filter((port) => !inputPorts.includes(port));
const singlePorts = data.ports.length === 1 ? data.ports : [];
return (
<div className={`sim-node sim-node-${data.componentType} ${selected ? "selected" : ""}`}>
<div className="node-symbol" aria-hidden="true">
{symbolForComponent(data.componentType)}
</div>
<div className="node-label">{data.label}</div>
{inputPorts.map((port, index) => (
<Handle
className="port-handle"
id={port}
key={port}
position={Position.Left}
style={{ top: `${35 + index * 24}%` }}
type="target"
/>
))}
{outputPorts.map((port, index) => (
<Handle
className="port-handle"
id={port}
key={port}
position={Position.Right}
style={{ top: `${35 + index * 24}%` }}
type="source"
/>
))}
{singlePorts.map((port) => (
<Handle
className="port-handle"
id={port}
key={port}
position={Position.Right}
type="source"
/>
))}
</div>
);
}
function symbolForComponent(type: string) {
if (type === "cylinder") {
return <div className="symbol-cylinder" />;
}
if (type === "tank") {
return <div className="symbol-tank" />;
}
if (type === "pipe") {
return <div className="symbol-pipe" />;
}
if (type === "orifice") {
return <div className="symbol-orifice" />;
}
return <div className="symbol-tee" />;
}
function FlowWorkbench() {
const [nodes, setNodes, onNodesChange] = useNodesState<SimulationNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<SimulationEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [projectName, setProjectName] = useState("demo-system");
const [simulationConfig, setSimulationConfig] = useState<SimulationConfig>(
defaultSimulationConfig,
);
const [xmlText, setXmlText] = useState("");
const [statusText, setStatusText] = useState("后端待调用");
const [simulationResult, setSimulationResult] = useState<SimulationResult | null>(null);
const [busy, setBusy] = useState(false);
const reactFlow = useReactFlow<SimulationNode, SimulationEdge>();
const nodeTypes = useMemo<NodeTypes>(
() => ({ simulationComponent: SimulationComponentNode }),
[],
);
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
const selectedDefinition = selectedNode
? componentDefinitions.find((definition) => definition.type === selectedNode.data.componentType)
: null;
const buildCurrentProject = useCallback(
() => buildProjectPayload(projectName, nodes, edges, simulationConfig),
[edges, nodes, projectName, simulationConfig],
);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((currentEdges) =>
addEdge(
{
...connection,
id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`,
},
currentEdges,
),
);
},
[setEdges],
);
const onDragStart = (event: DragEvent<HTMLButtonElement>, type: string) => {
event.dataTransfer.setData("application/system-component", type);
event.dataTransfer.effectAllowed = "move";
};
const onDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/system-component");
const definition = componentDefinitions.find((item) => item.type === type);
if (!definition) {
return;
}
const position = reactFlow.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const id = takeNextNodeId(definition.type);
const node: SimulationNode = {
id,
type: "simulationComponent",
position,
data: buildNodeData(definition),
};
setNodes((currentNodes) => [...currentNodes, node]);
setSelectedNodeId(id);
},
[reactFlow, setNodes],
);
const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => {
if (!selectedNodeId) {
return;
}
setNodes((currentNodes) =>
currentNodes.map((node) => (node.id === selectedNodeId ? updater(node) : node)),
);
};
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
setSimulationConfig((current) => ({
...current,
[key]: key === "method" ? value : Number(value),
}));
};
const generateXml = async () => {
await runBusyAction("正在调用后端生成 XML...", async () => {
const xml = await postJsonText("/api/reactflow/system-xml", buildCurrentProject());
setXmlText(xml);
setStatusText(`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`);
});
};
const downloadXml = async () => {
await runBusyAction("正在生成并下载 XML...", async () => {
const xml = await postJsonText("/api/reactflow/system-xml", buildCurrentProject());
setXmlText(xml);
downloadText(`${safeFilename(projectName)}.xml`, xml);
setStatusText("XML 已下载");
});
};
const saveProject = async () => {
await runBusyAction("正在保存工程...", async () => {
const project = buildCurrentProject();
const result = await postJson<SaveProjectResult>(
`/api/reactflow/projects/${encodeURIComponent(project.name)}`,
project,
);
setStatusText(
`工程已保存:${result.id},${result.componentCount} 个组件,${result.connectionCount} 条连接`,
);
});
};
const loadProject = async () => {
await runBusyAction("正在加载工程...", async () => {
const projectId = projectName.trim() || "untitled";
const project = await getJson<ReactFlowProjectPayload>(
`/api/reactflow/projects/${encodeURIComponent(projectId)}`,
);
const loadedNodes = project.nodes.map(normalizeLoadedNode);
setNodes(loadedNodes);
setEdges(project.edges.map(normalizeLoadedEdge));
setProjectName(project.name || projectId);
setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation });
setSelectedNodeId(null);
setSimulationResult(null);
setXmlText("");
syncNextNodeNumbers(loadedNodes);
setStatusText(`工程已加载:${loadedNodes.length} 个组件,${project.edges.length} 条连接`);
window.requestAnimationFrame(() => reactFlow.fitView());
});
};
const runSimulation = async () => {
await runBusyAction("正在调用 PythonModels 仿真...", async () => {
const result = await postJson<SimulationResult>(
"/api/reactflow/simulate-testmodel",
buildCurrentProject(),
);
setSimulationResult(result);
setStatusText(result.success ? "仿真完成" : `仿真返回:${result.message}`);
});
};
async function runBusyAction(message: string, action: () => Promise<void>) {
setBusy(true);
setStatusText(message);
try {
await action();
} catch (error) {
setStatusText(`操作失败:${formatError(error)}`);
} finally {
setBusy(false);
}
}
return (
<div className="app-shell">
<header className="topbar">
<div>
<div className="product-name">System Simulation Flow</div>
<div className="product-meta">React Flow 建模与仿真 MVP</div>
</div>
<div className="topbar-actions">
<label className="project-name-field">
<span>工程</span>
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} />
</label>
<button className="secondary-button" onClick={() => reactFlow.fitView()} type="button">
适配视图
</button>
<button className="secondary-button" disabled={busy} onClick={saveProject} type="button">
保存工程
</button>
<button className="secondary-button" disabled={busy} onClick={loadProject} type="button">
加载工程
</button>
<button className="secondary-button" disabled={busy} onClick={generateXml} type="button">
生成 XML
</button>
<button className="secondary-button" disabled={busy} onClick={downloadXml} type="button">
下载 XML
</button>
<button className="primary-button" disabled={busy} onClick={runSimulation} type="button">
运行仿真
</button>
</div>
</header>
<main className="workbench">
<aside className="palette">
<div className="panel-title">组件库</div>
<div className="palette-list">
{componentDefinitions.map((definition) => (
<button
className="palette-item"
draggable
key={definition.type}
onDragStart={(event) => onDragStart(event, definition.type)}
type="button"
>
<span className={`palette-icon icon-${definition.type}`} />
<span>
<strong>{definition.label}</strong>
<small>{definition.ports.join(" / ") || "无端口"}</small>
</span>
</button>
))}
</div>
</aside>
<section
className="flow-canvas"
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={onDrop}
>
<ReactFlow
colorMode="light"
edges={edges}
fitView
nodeTypes={nodeTypes}
nodes={nodes}
onConnect={onConnect}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onNodesChange={onNodesChange}
onPaneClick={() => setSelectedNodeId(null)}
onSelectionChange={({ nodes: selectedNodes }) =>
setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null)
}
panActivationKeyCode="Space"
panOnDrag={[1]}
selectionMode={SelectionMode.Partial}
selectionOnDrag
zoomOnDoubleClick={false}
>
<Background gap={18} />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</section>
<aside className="properties">
<div className="status-strip">{statusText}</div>
<div className="panel-title">组件参数</div>
{selectedNode && selectedDefinition ? (
<div className="parameter-form">
<label className="field">
<span>组件名称</span>
<input
value={selectedNode.data.label}
onChange={(event) =>
updateSelectedNode((node) => ({
...node,
data: { ...node.data, label: event.target.value },
}))
}
/>
</label>
<div className="node-id">{selectedNode.id}</div>
{Object.entries(selectedDefinition.parameters).map(([key, definition]) => (
<label className="field" key={key}>
<span>
{definition.label}
{definition.unit ? <em>{definition.unit}</em> : null}
</span>
<input
value={selectedNode.data.parameters[key] ?? ""}
onChange={(event) =>
updateSelectedNode((node) => ({
...node,
data: {
...node.data,
parameters: {
...node.data.parameters,
[key]: event.target.value,
},
},
}))
}
/>
</label>
))}
</div>
) : (
<div className="empty-state">选择画布上的组件后编辑参数</div>
)}
<div className="panel-title">仿真设置</div>
<div className="parameter-form compact-form">
<label className="field">
<span>起始时间</span>
<input
type="number"
value={simulationConfig.t_start}
onChange={(event) => updateSimulationConfig("t_start", event.target.value)}
/>
</label>
<label className="field">
<span>结束时间</span>
<input
type="number"
value={simulationConfig.t_stop}
onChange={(event) => updateSimulationConfig("t_stop", event.target.value)}
/>
</label>
<label className="field">
<span>采样步长</span>
<input
type="number"
value={simulationConfig.step}
onChange={(event) => updateSimulationConfig("step", event.target.value)}
/>
</label>
<label className="field">
<span>最大积分步长</span>
<input
type="number"
value={simulationConfig.max_step}
onChange={(event) => updateSimulationConfig("max_step", event.target.value)}
/>
</label>
<label className="field">
<span>求解器</span>
<input
value={simulationConfig.method}
onChange={(event) => updateSimulationConfig("method", event.target.value)}
/>
</label>
</div>
{simulationResult ? (
<>
<div className="panel-title">仿真结果</div>
<div className="result-section">
<div className="result-grid">
<span>时间</span>
<strong>{formatNumber(simulationResult.final.time)}</strong>
<span>贮箱压力</span>
<strong>{formatNumber(simulationResult.final.tankPressure)} Pa</strong>
<span>贮箱温度</span>
<strong>{formatNumber(simulationResult.final.tankTemperature)} K</strong>
<span>气瓶压力</span>
<strong>{formatNumber(simulationResult.final.cylinderPressure)} Pa</strong>
<span>气瓶温度</span>
<strong>{formatNumber(simulationResult.final.cylinderTemperature)} K</strong>
</div>
<div className="run-message">{simulationResult.message}</div>
<div className="artifact-list">
{Object.entries(simulationResult.artifacts).map(([key, path]) => (
<div key={key}>
<span>{key}</span>
<code>{path}</code>
</div>
))}
</div>
</div>
</>
) : null}
{xmlText ? (
<>
<div className="panel-title">系统 XML</div>
<textarea className="xml-output" readOnly value={xmlText} />
</>
) : null}
</aside>
</main>
</div>
);
}
function buildProjectPayload(
name: string,
nodes: SimulationNode[],
edges: SimulationEdge[],
simulation: SimulationConfig,
): ReactFlowProjectPayload {
return {
name: name.trim() || "untitled",
nodes: nodes.map((node) => ({
id: node.id,
type: "simulationComponent",
position: {
x: node.position.x,
y: node.position.y,
},
data: {
label: node.data.label,
componentType: node.data.componentType,
modelType: node.data.modelType,
ports: [...node.data.ports],
parameters: { ...node.data.parameters },
},
})),
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle ?? null,
targetHandle: edge.targetHandle ?? null,
})),
simulation,
};
}
function normalizeLoadedNode(node: ProjectNodePayload): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const parameters = definition
? { ...defaultParameters(definition), ...node.data.parameters }
: { ...node.data.parameters };
return {
id: node.id,
type: "simulationComponent",
position: {
x: node.position?.x ?? 0,
y: node.position?.y ?? 0,
},
data: {
label: node.data.label || definition?.label || node.id,
componentType: node.data.componentType || definition?.type || "component",
modelType: node.data.modelType || definition?.modelType || node.data.componentType,
ports: node.data.ports?.length ? node.data.ports : definition?.ports ?? [],
parameters,
},
};
}
function normalizeLoadedEdge(edge: ProjectEdgePayload): SimulationEdge {
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
};
}
function takeNextNodeId(componentType: string) {
const nextNumber = nextNodeNumberByType[componentType] ?? 1;
nextNodeNumberByType[componentType] = nextNumber + 1;
return `${componentType}_${nextNumber}`;
}
function syncNextNodeNumbers(nodes: SimulationNode[]) {
Object.keys(nextNodeNumberByType).forEach((type) => {
delete nextNodeNumberByType[type];
});
nodes.forEach((node) => {
const componentType = node.data.componentType;
const idPrefix = `${componentType}_`;
if (!node.id.startsWith(idPrefix)) {
return;
}
const nodeNumber = Number(node.id.slice(idPrefix.length));
if (!Number.isInteger(nodeNumber) || nodeNumber < 1) {
return;
}
nextNodeNumberByType[componentType] = Math.max(
nextNodeNumberByType[componentType] ?? 1,
nodeNumber + 1,
);
});
}
async function postJson<TResponse>(url: string, body: unknown): Promise<TResponse> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as TResponse;
}
async function postJsonText(url: string, body: unknown): Promise<string> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
return response.text();
}
async function getJson<TResponse>(url: string): Promise<TResponse> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as TResponse;
}
async function readApiError(response: Response) {
const text = await response.text();
if (!text) {
return `${response.status} ${response.statusText}`;
}
try {
const parsed = JSON.parse(text) as { detail?: unknown };
if (typeof parsed.detail === "string") {
return parsed.detail;
}
} catch {
return text;
}
return text;
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "application/xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function safeFilename(value: string) {
return (value.trim() || "system").replace(/[\\/:*?"<>|]+/g, "_");
}
function formatError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function formatNumber(value: number) {
if (!Number.isFinite(value)) {
return "-";
}
if (Math.abs(value) >= 100000 || (Math.abs(value) > 0 && Math.abs(value) < 0.001)) {
return value.toExponential(4);
}
return value.toFixed(4).replace(/\.?0+$/, "");
}
export default function App() {
return (
<ReactFlowProvider>
<FlowWorkbench />
</ReactFlowProvider>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "@xyflow/react/dist/style.css";
import "./styles.css";
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<App />
</StrictMode>,
);
+481
View File
@@ -0,0 +1,481 @@
:root {
color: #1f2933;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: #eef2f6;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
.app-shell {
display: grid;
grid-template-rows: 72px minmax(0, 1fr);
width: 100%;
height: 100%;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 14px;
border-bottom: 1px solid #cfd8e3;
background: #ffffff;
}
.product-name {
font-size: 15px;
font-weight: 700;
}
.product-meta {
margin-top: 2px;
color: #64748b;
font-size: 12px;
}
.topbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.primary-button,
.secondary-button {
height: 32px;
padding: 0 12px;
border-radius: 6px;
cursor: pointer;
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.primary-button {
border: 1px solid #1d6fb8;
color: #ffffff;
background: #1d6fb8;
}
.secondary-button {
border: 1px solid #c2ccda;
color: #263442;
background: #ffffff;
}
.project-name-field {
display: flex;
align-items: center;
gap: 6px;
color: #475569;
font-size: 12px;
font-weight: 650;
}
.project-name-field input {
width: 150px;
height: 32px;
padding: 0 9px;
border: 1px solid #c2ccda;
border-radius: 6px;
color: #1f2933;
}
.workbench {
display: grid;
grid-template-columns: 230px minmax(0, 1fr) 330px;
min-height: 0;
}
.palette,
.properties {
min-width: 0;
min-height: 0;
overflow: auto;
border-color: #cfd8e3;
background: #ffffff;
}
.palette {
border-right: 1px solid #cfd8e3;
}
.properties {
border-left: 1px solid #cfd8e3;
}
.status-strip {
padding: 10px 14px;
border-bottom: 1px solid #e3e8ef;
color: #155e75;
background: #ecfeff;
font-size: 12px;
font-weight: 650;
}
.panel-title {
padding: 14px;
border-bottom: 1px solid #e3e8ef;
font-size: 13px;
font-weight: 700;
}
.palette-list,
.parameter-form {
display: grid;
gap: 10px;
padding: 12px;
}
.compact-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-form .field:last-child {
grid-column: 1 / -1;
}
.palette-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 52px;
padding: 8px;
border: 1px solid #d6dee9;
border-radius: 6px;
color: #22303f;
text-align: left;
background: #ffffff;
cursor: grab;
}
.palette-item small {
display: block;
margin-top: 2px;
overflow: hidden;
color: #64748b;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-icon {
display: block;
width: 32px;
height: 32px;
border: 2px solid #4b5563;
background: #f8fafc;
}
.icon-cylinder {
border-radius: 13px;
background: #dbeafe;
}
.icon-tank {
border-radius: 50%;
background: #dcfce7;
}
.icon-pipe {
height: 14px;
border-radius: 8px;
background: #f8fafc;
}
.icon-orifice {
transform: rotate(45deg);
background: #ffedd5;
}
.icon-tee {
position: relative;
border: 0;
}
.icon-tee::before,
.icon-tee::after {
position: absolute;
content: "";
border-radius: 6px;
background: #e5e7eb;
border: 2px solid #4b5563;
}
.icon-tee::before {
top: 12px;
left: 0;
width: 32px;
height: 10px;
}
.icon-tee::after {
top: 0;
left: 11px;
width: 10px;
height: 32px;
}
.flow-canvas {
min-width: 0;
min-height: 0;
background: #f8fafc;
}
.flow-canvas .react-flow__pane,
.flow-canvas .react-flow__pane.selection,
.flow-canvas .react-flow__node.selectable,
.flow-canvas .react-flow__node.draggable {
cursor: default;
}
.flow-canvas .react-flow__pane.dragging {
cursor: grabbing;
}
.flow-canvas .react-flow__node.draggable.dragging {
cursor: move;
}
.sim-node {
position: relative;
display: grid;
place-items: center;
min-width: 96px;
min-height: 58px;
padding: 8px 16px;
border: 2px solid #4b5563;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
}
.sim-node.selected {
border-color: #1d6fb8;
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.15);
}
.node-symbol {
height: 28px;
}
.node-label {
max-width: 120px;
overflow: hidden;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.symbol-cylinder,
.symbol-tank,
.symbol-pipe,
.symbol-orifice,
.symbol-tee {
width: 42px;
height: 22px;
border: 2px solid #4b5563;
background: #f8fafc;
}
.symbol-cylinder {
height: 28px;
border-radius: 12px;
background: #dbeafe;
}
.symbol-tank {
height: 28px;
border-radius: 50%;
background: #dcfce7;
}
.symbol-pipe {
height: 12px;
margin-top: 8px;
border-radius: 8px;
}
.symbol-orifice {
width: 26px;
height: 26px;
transform: rotate(45deg);
background: #ffedd5;
}
.symbol-tee {
position: relative;
border: 0;
}
.symbol-tee::before,
.symbol-tee::after {
position: absolute;
content: "";
border: 2px solid #4b5563;
border-radius: 6px;
background: #f8fafc;
}
.symbol-tee::before {
top: 8px;
left: 0;
width: 42px;
height: 12px;
}
.symbol-tee::after {
top: 0;
left: 14px;
width: 12px;
height: 28px;
}
.port-handle {
width: 10px;
height: 10px;
border: 2px solid #374151;
background: #ffffff;
}
.field {
display: grid;
gap: 6px;
color: #334155;
font-size: 12px;
font-weight: 650;
}
.field em {
margin-left: 6px;
color: #64748b;
font-style: normal;
font-weight: 400;
}
.field input {
width: 100%;
height: 34px;
padding: 0 9px;
border: 1px solid #c2ccda;
border-radius: 6px;
color: #1f2933;
}
.node-id {
padding-bottom: 8px;
border-bottom: 1px solid #e3e8ef;
color: #64748b;
font-size: 12px;
}
.empty-state {
padding: 16px;
color: #64748b;
font-size: 13px;
}
.xml-output {
width: calc(100% - 24px);
min-height: 220px;
margin: 12px;
padding: 10px;
border: 1px solid #c2ccda;
border-radius: 6px;
color: #1f2933;
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
resize: vertical;
}
.result-section {
display: grid;
gap: 12px;
padding: 12px;
}
.result-grid {
display: grid;
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
gap: 8px 10px;
align-items: baseline;
font-size: 12px;
}
.result-grid span {
color: #64748b;
}
.result-grid strong {
overflow-wrap: anywhere;
color: #1f2933;
font-size: 12px;
}
.run-message {
padding: 8px;
border: 1px solid #d6dee9;
border-radius: 6px;
color: #334155;
background: #f8fafc;
font-size: 12px;
}
.artifact-list {
display: grid;
gap: 8px;
}
.artifact-list div {
display: grid;
gap: 4px;
color: #64748b;
font-size: 11px;
}
.artifact-list code {
overflow-wrap: anywhere;
color: #1f2933;
font-family: Consolas, "Courier New", monospace;
font-size: 11px;
}
@media (max-width: 1100px) {
.workbench {
grid-template-columns: 180px minmax(0, 1fr) 280px;
}
.topbar {
align-items: flex-start;
padding-top: 8px;
}
.project-name-field input {
width: 120px;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1
View File
@@ -0,0 +1 @@
powershell -ExecutionPolicy Bypass -File "%~dp0start-dev.ps1"
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
Sort-Object Name -Descending |
Select-Object -First 1
if (-not $nodeDir) {
throw "Node.js portable runtime was not found under .tools."
}
$env:Path = "$($nodeDir.FullName);$env:Path"
& (Join-Path $nodeDir.FullName "npm.cmd") run dev
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8000",
},
},
});