Files
SystemSimulationApp/frontend/src/App.tsx
T

818 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}