Commit 3a68f372 authored by Cloud's avatar Cloud

feat: 添加仿真结果图表弹窗查看器

- 新增 SimResultsModal 组件(CSV 解析 + Recharts 折线图)
- ProjectPanel 执行成功后显示「查看结果」按钮
- 新增 useProjectStore 编译/执行状态管理
- 新增 api.js 后端接口封装
- 新增 ProjectPanel 项目管理面板
- 添加接口文档
- 安装 recharts、react-router-dom 依赖
parent d194a39f
---
name: backend-integration
description: 后端项目路径与接口文档位置说明
---
# 后端集成说明
## 后端代码位置
本项目(前端)的后端代码位于**同级目录**下的 `openmodelica/sim_backend/`
```
~/code/
eplanvisualizer/ ← 前端项目(本项目)
openmodelica/
sim_backend/ ← 后端项目(Drogon C++17)
CMakeLists.txt
config.yaml ← 后端配置(端口 8080)
src/
main.cpp
controllers/ ← HTTP 控制器
services/ ← 业务逻辑
docs/ ← 接口文档
```
绝对路径:`/home/cloud/code/openmodelica/sim_backend/`
## 接口文档
后端接口文档存放在 `sim_backend/docs/` 目录下。
修改或新增前后端交互接口时,**必须同步更新接口文档**
## 后端技术栈
| 层级 | 技术 | 版本 |
|------|------|------|
| 语言 | C++ | C++17 |
| Web 框架 | Drogon | - |
| 构建 | CMake | ≥ 3.16 |
| 默认端口 | 8080 | - |
## 关键规则
1. 前后端接口变更时须同步更新 `sim_backend/docs/` 中的接口文档
2. 后端项目有独立的 Skill 规范,位于 `openmodelica/.agents/skills/`
3. 跨项目调试时注意后端默认端口为 **8080**
# SimuFlow 后端接口文档
> 基础路径:`http://localhost:8888/api`
> 前端开发服务器通过 Vite 代理转发 `/api` 至后端
## 通用规范
- **请求/响应字段**:统一使用 `snake_case`
- **响应信封格式**
```json
{
"code": 0,
"message": "ok",
"data": { ... }
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | `0` = 成功,`1xxx` = 编译错误,`2xxx` = 执行错误,`400` = 参数错误 |
| message | string | 人可读状态描述 |
| data | object/null | 业务数据 |
---
## GET /api/health
健康检查。
```json
{ "status": "ok", "version": "0.1.0" }
```
---
## POST /api/compile
编译 Modelica 模型。
**请求:**
```json
{
"mo_code": "model Circuit\n ...\nend Circuit;",
"model_name": "Circuit"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| mo_code | string | ✅ | .mo 源代码 |
| model_name | string | ✅ | 模型名称 |
**成功响应** (`code: 0`):
```json
{
"code": 0,
"message": "编译成功",
"data": {
"compile_output": "omc 输出日志...",
"model_name": "Circuit"
}
}
```
**失败响应** (`code: 1001`):
```json
{
"code": 1001,
"message": "编译失败",
"data": {
"compile_output": "omc 输出日志...",
"error_detail": "错误详情...",
"model_name": "Circuit"
}
}
```
---
## POST /api/execute
执行已编译的仿真模型。须先编译成功。
**请求:**
```json
{ "model_name": "Circuit" }
```
**成功响应** (`code: 0`):
```json
{
"code": 0,
"message": "仿真完成",
"data": {
"sim_log": "仿真日志...",
"csv_data": "time,v1,v2\n0,0,0\n...",
"model_name": "Circuit"
}
}
```
**失败响应** (`code: 2001`):
```json
{
"code": 2001,
"message": "仿真执行失败",
"data": {
"sim_log": "错误日志...",
"model_name": "Circuit"
}
}
```
---
## 环境要求
- 后端需安装 OpenModelica,确保 `omc` 命令可用
- 编译产物存放在 `/tmp/simuflow_workspace/`
......@@ -12,6 +12,8 @@
"dagre": "^0.8.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
},
......@@ -1010,6 +1012,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
......@@ -1367,6 +1405,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
......@@ -1412,6 +1462,12 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz",
......@@ -1427,6 +1483,12 @@
"@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",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
......@@ -1436,12 +1498,48 @@
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@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",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"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",
......@@ -1495,6 +1593,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
......@@ -1780,6 +1884,15 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
......@@ -1823,6 +1936,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
......@@ -1857,6 +1983,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
......@@ -1897,6 +2035,15 @@
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
......@@ -1909,6 +2056,31 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
......@@ -1918,6 +2090,42 @@
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
......@@ -1990,6 +2198,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
......@@ -2004,6 +2218,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz",
......@@ -2253,6 +2477,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
......@@ -2449,6 +2679,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
......@@ -2476,6 +2716,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
......@@ -2862,6 +3111,36 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz",
......@@ -2872,6 +3151,95 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
......@@ -2943,6 +3311,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
......@@ -3014,6 +3388,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
......@@ -3094,6 +3474,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",
......
/**
* ProjectPanel - 项目管理面板
* 功能:保存/打开/重命名/删除多个原理图,编译/执行 Modelica 模型
*/
import { useState, useCallback, useEffect } from 'react';
import useProjectStore from '../../hooks/useProjectStore';
import useFlowStore from '../../hooks/useFlowStore';
import SimResultsModal from '../SimResults/SimResultsModal';
import styles from './ProjectPanel.module.css';
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function StatusBadge({ status, labels }) {
const map = {
none: { cls: styles.statusNone, text: labels?.none || '未编译' },
compiling: { cls: styles.statusCompiling, text: '编译中…' },
running: { cls: styles.statusRunning, text: '执行中…' },
success: { cls: styles.statusSuccess, text: labels?.success || '成功' },
error: { cls: styles.statusError, text: labels?.error || '失败' },
};
const info = map[status] || map.none;
return <span className={`${styles.statusBadge} ${info.cls}`}>{info.text}</span>;
}
export default function ProjectPanel() {
const {
projects,
activeProjectId,
panelOpen,
closePanel,
saveProject,
saveAsProject,
openProject,
renameProject,
deleteProject,
updateModelName,
compileProject,
executeProject,
} = useProjectStore();
const nodes = useFlowStore(s => s.nodes);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [showCompileLog, setShowCompileLog] = useState(false);
const [showExecuteLog, setShowExecuteLog] = useState(false);
const [showResults, setShowResults] = useState(false);
const activeProject = projects.find(p => p.id === activeProjectId) || null;
// Ctrl+S 快捷键
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (activeProjectId) {
saveProject();
} else if (nodes.length > 0) {
const name = prompt('请输入原理图名称', `原理图 ${projects.length + 1}`);
if (name) saveProject(name);
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [activeProjectId, saveProject, nodes.length, projects.length]);
const handleSave = useCallback(() => {
if (activeProjectId) {
saveProject();
} else {
const name = prompt('请输入原理图名称', `原理图 ${projects.length + 1}`);
if (name) saveProject(name);
}
}, [activeProjectId, saveProject, projects.length]);
const handleSaveAs = useCallback(() => {
const name = prompt('请输入新原理图名称', `原理图 ${projects.length + 1}`);
if (name) saveAsProject(name);
}, [saveAsProject, projects.length]);
const startRename = useCallback((id, currentName) => {
setRenamingId(id);
setRenameValue(currentName);
}, []);
const confirmRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
renameProject(renamingId, renameValue.trim());
}
setRenamingId(null);
}, [renamingId, renameValue, renameProject]);
const handleDelete = useCallback((id, name) => {
if (window.confirm(`确认删除「${name}」?此操作不可恢复。`)) {
deleteProject(id);
}
}, [deleteProject]);
if (!panelOpen) return null;
return (
<div className={styles.panel}>
{/* 头部 */}
<div className={styles.header}>
<span className={styles.headerTitle}>📁 项目管理</span>
<button className={styles.closeBtn} onClick={closePanel} title="关闭"></button>
</div>
{/* 操作栏 */}
<div className={styles.actions}>
<button className={`${styles.actionBtn} ${styles.primary}`} onClick={handleSave}>
💾 保存
</button>
<button className={styles.actionBtn} onClick={handleSaveAs}>
📄 另存为
</button>
</div>
{/* 滚动内容 */}
<div className={styles.content}>
{/* 项目列表 */}
<div className={styles.projectList}>
{projects.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<div>暂无保存的原理图</div>
<div style={{ fontSize: 10, marginTop: 4, color: '#444' }}>
点击「保存」将当前画布保存为项目
</div>
</div>
) : (
projects.map(p => (
<div
key={p.id}
className={`${styles.projectItem} ${p.id === activeProjectId ? styles.active : ''}`}
onClick={() => openProject(p.id)}
>
<span className={styles.projectIcon}>
{p.compileStatus === 'success' ? '✅' : p.compileStatus === 'error' ? '❌' : '📄'}
</span>
<div className={styles.projectInfo}>
{renamingId === p.id ? (
<input
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={confirmRename}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') setRenamingId(null);
}}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={styles.projectName}>{p.name}</div>
)}
<div className={styles.projectMeta}>
{p.nodes?.length || 0} 节点 · {formatTime(p.updatedAt)}
</div>
</div>
<div className={styles.projectActions}>
<button
className={styles.iconBtn}
onClick={(e) => { e.stopPropagation(); startRename(p.id, p.name); }}
title="重命名"
>
✏️
</button>
<button
className={`${styles.iconBtn} ${styles.danger}`}
onClick={(e) => { e.stopPropagation(); handleDelete(p.id, p.name); }}
title="删除"
>
🗑
</button>
</div>
</div>
))
)}
</div>
{/* 编译/执行区域 — 仅当有活动项目时显示 */}
{activeProject && (
<div className={styles.simSection}>
<div className={styles.sectionLabel}>编译 &amp; 执行</div>
{/* 模型名称 */}
<div className={styles.modelNameRow}>
<span style={{ fontSize: 11, color: '#666', flexShrink: 0 }}>模型名:</span>
<input
className={styles.modelNameInput}
value={activeProject.modelName || 'Circuit'}
onChange={(e) => updateModelName(activeProject.id, e.target.value)}
placeholder="Circuit"
/>
</div>
{/* 编译/执行按钮 */}
<div className={styles.simBtnRow}>
<button
className={`${styles.simBtn} ${styles.compile}`}
onClick={() => compileProject(activeProject.id)}
disabled={activeProject.compileStatus === 'compiling'}
>
{activeProject.compileStatus === 'compiling' ? (
<><span className={styles.spinner}></span> 编译中…</>
) : (
<>▶ 编译</>
)}
</button>
<button
className={`${styles.simBtn} ${styles.execute}`}
onClick={() => executeProject(activeProject.id)}
disabled={
activeProject.compileStatus !== 'success' ||
activeProject.executeStatus === 'running'
}
title={activeProject.compileStatus !== 'success' ? '请先编译成功' : '执行仿真'}
>
{activeProject.executeStatus === 'running' ? (
<><span className={styles.spinner}></span> 执行中…</>
) : (
<>⏵ 执行</>
)}
</button>
</div>
{/* 查看结果按钮 — 仅执行成功且有 CSV 数据时显示 */}
{activeProject.executeStatus === 'success' && activeProject.executeResult?.csvData && (
<button
className={`${styles.simBtn} ${styles.viewResults}`}
onClick={() => setShowResults(true)}
style={{ width: '100%', marginBottom: 8 }}
>
📊 查看结果
</button>
)}
{/* 编译状态 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 10, color: '#666' }}>编译:</span>
<StatusBadge
status={activeProject.compileStatus}
labels={{ none: '未编译', success: '编译成功', error: '编译失败' }}
/>
<span style={{ fontSize: 10, color: '#666', marginLeft: 8 }}>执行:</span>
<StatusBadge
status={activeProject.executeStatus}
labels={{ none: '未执行', success: '执行完成', error: '执行失败' }}
/>
</div>
{/* 编译日志 */}
{activeProject.compileResult && (
<div className={styles.logSection}>
<button
className={styles.logToggle}
onClick={() => setShowCompileLog(v => !v)}
>
{showCompileLog ? '▼' : '▶'} 编译输出
</button>
{showCompileLog && (
<div className={`${styles.logContent} ${activeProject.compileStatus === 'error' ? styles.logError : styles.logSuccess}`}>
{activeProject.compileResult.errors || activeProject.compileResult.output || '无输出'}
</div>
)}
</div>
)}
{/* 执行日志 */}
{activeProject.executeResult && (
<div className={styles.logSection}>
<button
className={styles.logToggle}
onClick={() => setShowExecuteLog(v => !v)}
>
{showExecuteLog ? '▼' : '▶'} 执行结果
</button>
{showExecuteLog && (
<div className={`${styles.logContent} ${activeProject.executeStatus === 'error' ? styles.logError : ''}`}>
{activeProject.executeResult.logs || activeProject.executeResult.csvData || '无输出'}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* 仿真结果弹窗 */}
{showResults && activeProject?.executeResult?.csvData && (
<SimResultsModal
csvData={activeProject.executeResult.csvData}
modelName={activeProject.modelName || 'Circuit'}
onClose={() => setShowResults(false)}
/>
)}
</div>
);
}
/* ===== 项目面板容器 ===== */
.panel {
width: 320px;
background: #16161e;
border-left: 1px solid #2a2a3a;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.2s ease;
}
.panelCollapsed {
width: 0;
border-left: none;
overflow: hidden;
}
/* ===== 头部 ===== */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
}
.headerTitle {
font-size: 13px;
font-weight: 700;
color: #bbb;
letter-spacing: 0.5px;
}
.closeBtn {
background: none;
border: none;
color: #666;
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.12s;
}
.closeBtn:hover {
background: #2a2a3e;
color: #e0e0e0;
}
/* ===== 操作栏 ===== */
.actions {
display: flex;
gap: 6px;
padding: 12px 16px;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
}
.actionBtn {
flex: 1;
padding: 7px 8px;
border: 1px solid #333;
border-radius: 6px;
background: #1e1e2e;
color: #bbb;
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-align: center;
}
.actionBtn:hover:not(:disabled) {
background: #2a2a3e;
color: #e0e0e0;
border-color: #555;
}
.actionBtn:active:not(:disabled) {
transform: scale(0.97);
}
.actionBtn.primary {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.actionBtn.primary:hover:not(:disabled) {
background: #5558e6;
}
/* ===== 滚动内容区 ===== */
.content {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
/* ===== 项目列表 ===== */
.projectList {
padding: 8px;
}
.projectItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #2a2a3a;
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.15s ease;
background: rgba(255, 255, 255, 0.02);
}
.projectItem:hover {
border-color: #444;
background: rgba(255, 255, 255, 0.04);
}
.projectItem.active {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.08);
}
.projectIcon {
font-size: 18px;
color: #555;
flex-shrink: 0;
}
.projectItem.active .projectIcon {
color: #6366f1;
}
.projectInfo {
flex: 1;
min-width: 0;
}
.projectName {
font-size: 12px;
font-weight: 600;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.projectMeta {
font-size: 9px;
color: #555;
margin-top: 2px;
}
.projectActions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.iconBtn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
}
.iconBtn:hover {
background: #2a2a4a;
color: #bbb;
}
.iconBtn.danger:hover {
background: #dc2626;
color: #fff;
}
/* ===== 编译/执行区域 ===== */
.simSection {
padding: 12px 16px;
border-top: 1px solid #2a2a3a;
}
.sectionLabel {
font-size: 10px;
font-weight: 700;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.modelNameRow {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 10px;
}
.modelNameInput {
flex: 1;
padding: 5px 8px;
border: 1px solid #333;
border-radius: 4px;
background: #1e1e2e;
color: #ccc;
font-size: 12px;
font-family: 'Consolas', monospace;
}
.modelNameInput:focus {
outline: none;
border-color: #6366f1;
}
.simBtnRow {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.simBtn {
flex: 1;
padding: 8px 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1e1e2e;
color: #bbb;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.simBtn:hover:not(:disabled) {
background: #2a2a3e;
color: #e0e0e0;
border-color: #555;
}
.simBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.simBtn.compile {
background: #1a2a1a;
border-color: #2a5a2a;
color: #6ec66e;
}
.simBtn.compile:hover:not(:disabled) {
background: #1e3a1e;
border-color: #4a8a4a;
}
.simBtn.execute {
background: #1a1a2e;
border-color: #2a2a6a;
color: #8888f0;
}
.simBtn.execute:hover:not(:disabled) {
background: #1e1e3e;
border-color: #4a4a9a;
}
.simBtn.viewResults {
background: #0a2a2a;
border-color: #0d6e6e;
color: #2dd4bf;
font-weight: 700;
letter-spacing: 0.5px;
}
.simBtn.viewResults:hover:not(:disabled) {
background: #0e3a3a;
border-color: #14b8a6;
color: #5eead4;
box-shadow: 0 0 12px rgba(45, 212, 191, 0.15);
}
/* ===== 状态指示 ===== */
.statusBadge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
.statusNone {
background: rgba(255, 255, 255, 0.05);
color: #666;
}
.statusCompiling,
.statusRunning {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.statusSuccess {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.statusError {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* ===== 日志输出 ===== */
.logSection {
margin-top: 8px;
}
.logToggle {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
background: none;
border: none;
color: #666;
font-size: 10px;
cursor: pointer;
transition: color 0.12s;
}
.logToggle:hover {
color: #aaa;
}
.logContent {
margin-top: 4px;
padding: 8px;
background: #0d0d15;
border: 1px solid #1a1a2a;
border-radius: 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 10px;
color: #aaa;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
}
.logError {
color: #ef4444;
}
.logSuccess {
color: #22c55e;
}
/* ===== 空状态 ===== */
.emptyState {
text-align: center;
padding: 40px 16px;
color: #555;
font-size: 12px;
}
.emptyIcon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.3;
}
/* ===== 重命名输入框 ===== */
.renameInput {
width: 100%;
padding: 2px 4px;
border: 1px solid #6366f1;
border-radius: 3px;
background: #0d0d15;
color: #ccc;
font-size: 12px;
font-weight: 600;
outline: none;
}
/* ===== 加载动画 ===== */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/**
* SimResultsModal - 仿真结果图表查看器(弹窗模式)
* 解析 CSV 数据,以 Recharts 折线图展示变量随时间变化
*
* 参考:lcr_oscillator/results/index.html 的 Chart.js 做法
* 改用 Recharts (React 生态) 实现等效功能
*/
import { useState, useMemo, useCallback } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Brush,
} from 'recharts';
import styles from './SimResultsModal.module.css';
/** 预设调色板 — 参考 lcr_oscillator 的 accent 色系 */
const COLORS = [
'#4f8aff', '#22c55e', '#f59e0b', '#ef4444', '#00d4ff',
'#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b',
'#84cc16', '#e879f9', '#2dd4bf', '#fb923c', '#6366f1',
];
/** 解析 CSV 字符串 → [{time, var1, var2, ...}, ...] */
function parseCSV(csv) {
if (!csv || typeof csv !== 'string') return { headers: [], data: [] };
const lines = csv.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return { headers: [], data: [] };
// 首行为 header,可能带引号
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
const data = [];
for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(',');
if (vals.length !== headers.length) continue;
const row = {};
let valid = true;
for (let j = 0; j < headers.length; j++) {
const num = parseFloat(vals[j]);
if (isNaN(num)) { valid = false; break; }
row[headers[j]] = num;
}
if (valid) data.push(row);
}
return { headers, data };
}
/** 自定义 Tooltip — 暗色主题 */
function CustomTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
return (
<div style={{
background: '#1a1a2e',
border: '1px solid rgba(79,138,255,0.2)',
borderRadius: 10,
padding: '10px 14px',
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}>
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 6 }}>
time = {typeof label === 'number' ? label.toFixed(6) : label}
</div>
{payload.map((entry, i) => (
<div key={i} style={{ fontSize: 12, color: entry.color, marginBottom: 2 }}>
{entry.name}: <strong>{typeof entry.value === 'number' ? entry.value.toFixed(6) : entry.value}</strong>
</div>
))}
</div>
);
}
/**
* @param {{csvData: string, modelName: string, onClose: () => void}} props
*/
export default function SimResultsModal({ csvData, modelName, onClose }) {
const [selectedVars, setSelectedVars] = useState(null);
const { headers, data } = useMemo(() => parseCSV(csvData), [csvData]);
// X 轴 key(通常是 time)
const xKey = useMemo(() => {
return headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time';
}, [headers]);
// 变量列表(排除 time)
const variables = useMemo(() => {
return headers.filter(h => h !== xKey);
}, [headers, xKey]);
// 初始化:默认选中前 5 个
if (selectedVars === null && variables.length > 0) {
const initial = new Set(variables.slice(0, Math.min(5, variables.length)));
// 使用同步设定避免闪烁
setSelectedVars(initial);
return null; // 重渲染一次
}
const selected = selectedVars || new Set();
const selectedArr = [...selected];
const toggleVar = (varName) => {
setSelectedVars(prev => {
const next = new Set(prev);
if (next.has(varName)) next.delete(varName);
else next.add(varName);
return next;
});
};
const selectAll = () => setSelectedVars(new Set(variables));
const selectNone = () => setSelectedVars(new Set());
// 数据质量检测
const hasValidData = data.length > 0 && variables.length > 0;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* 头部 */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.headerIcon}>📊</span>
<span className={styles.headerTitle}>仿真结果</span>
<span className={styles.headerModel}>{modelName}</span>
</div>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{!hasValidData ? (
<div className={styles.errorState}>
<div className={styles.errorIcon}>⚠️</div>
<div className={styles.errorText}>无法解析仿真数据</div>
<div className={styles.errorHint}>
CSV 数据格式无效或为空。请检查后端输出是否为标准 CSV 格式。
</div>
</div>
) : (
<div className={styles.body}>
{/* 统计卡片 */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}>数据点</div>
<div className={styles.statValue}>{data.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>变量数</div>
<div className={styles.statValue}>{variables.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>已选</div>
<div className={styles.statValue}>{selectedArr.length}</div>
</div>
{data.length > 0 && (
<div className={styles.statCard}>
<div className={styles.statLabel}>时间范围</div>
<div className={styles.statValue}>
{data[0][xKey]?.toFixed(3)} ~ {data[data.length - 1][xKey]?.toFixed(3)}
</div>
</div>
)}
</div>
<div className={styles.content}>
{/* 左侧变量面板 */}
<div className={styles.varPanel}>
<div className={styles.varHeader}>
<span className={styles.varTitle}>变量</span>
<div className={styles.selectActions}>
<button className={styles.selectBtn} onClick={selectAll}>全选</button>
<button className={styles.selectBtn} onClick={selectNone}>清除</button>
</div>
</div>
<div className={styles.varList}>
{variables.map((v, i) => {
const color = COLORS[i % COLORS.length];
const checked = selected.has(v);
return (
<div key={v} className={styles.varItem} onClick={() => toggleVar(v)}>
<div
className={`${styles.varCheck} ${checked ? styles.checked : ''}`}
style={checked ? { background: color, borderColor: color } : {}}
/>
<span className={styles.varDot} style={{ background: color }} />
<span className={styles.varName} title={v}>{v}</span>
</div>
);
})}
</div>
</div>
{/* 图表区域 */}
<div className={styles.chartArea}>
{selectedArr.length === 0 ? (
<div className={styles.emptyChart}>
<div style={{ fontSize: 40, opacity: 0.3 }}>📈</div>
<div>请在左侧选择要显示的变量</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(79,138,255,0.1)" />
<XAxis
dataKey={xKey}
stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
/>
<YAxis
stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(2) : v}
/>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#9ca3af' }} />
{data.length > 50 && (
<Brush
dataKey={xKey}
height={22}
stroke="#4f8aff"
fill="#0f0f1a"
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
/>
)}
{selectedArr.map((varName) => (
<Line
key={varName}
type="monotone"
dataKey={varName}
stroke={COLORS[variables.indexOf(varName) % COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}
/* ===== 仿真结果弹窗 ===== */
/* 遮罩层 */
.overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 弹窗主体 */
.modal {
width: 92vw;
height: 88vh;
max-width: 1400px;
background: #0f0f1a;
border: 1px solid rgba(79, 138, 255, 0.15);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), 0 0 40px rgba(79, 138, 255, 0.06);
animation: slideUp 0.25s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(24px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* 头部 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(79, 138, 255, 0.15);
flex-shrink: 0;
background: #12121f;
}
.headerLeft {
display: flex;
align-items: center;
gap: 10px;
}
.headerIcon {
font-size: 20px;
}
.headerTitle {
font-size: 16px;
font-weight: 700;
color: #e8e8f0;
letter-spacing: 0.3px;
}
.headerModel {
font-size: 12px;
color: #4f8aff;
padding: 3px 10px;
background: rgba(79, 138, 255, 0.1);
border: 1px solid rgba(79, 138, 255, 0.2);
border-radius: 20px;
}
.closeBtn {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #888;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.closeBtn:hover {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* 主体 */
.body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 统计卡片 */
.statsGrid {
display: flex;
gap: 12px;
padding: 16px 24px;
flex-shrink: 0;
border-bottom: 1px solid rgba(79, 138, 255, 0.08);
}
.statCard {
padding: 10px 16px;
background: #1a1a2e;
border: 1px solid rgba(79, 138, 255, 0.12);
border-radius: 10px;
min-width: 100px;
transition: all 0.2s;
}
.statCard:hover {
border-color: rgba(79, 138, 255, 0.3);
background: #1f1f35;
}
.statLabel {
font-size: 10px;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 4px;
}
.statValue {
font-size: 15px;
font-weight: 700;
color: #e8e8f0;
font-family: 'Consolas', 'Monaco', monospace;
}
/* 内容区 */
.content {
flex: 1;
display: flex;
overflow: hidden;
}
/* 变量面板 */
.varPanel {
width: 220px;
border-right: 1px solid rgba(79, 138, 255, 0.1);
display: flex;
flex-direction: column;
flex-shrink: 0;
background: #12121f;
}
.varHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid rgba(79, 138, 255, 0.08);
flex-shrink: 0;
}
.varTitle {
font-size: 11px;
font-weight: 700;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.8px;
}
.selectActions {
display: flex;
gap: 4px;
}
.selectBtn {
padding: 2px 8px;
border: 1px solid rgba(79, 138, 255, 0.15);
border-radius: 4px;
background: transparent;
color: #9ca3af;
font-size: 10px;
cursor: pointer;
transition: all 0.12s;
}
.selectBtn:hover {
background: rgba(79, 138, 255, 0.1);
color: #4f8aff;
border-color: rgba(79, 138, 255, 0.3);
}
.varList {
flex: 1;
overflow-y: auto;
padding: 6px;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.varItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.varItem:hover {
background: rgba(79, 138, 255, 0.06);
}
.varCheck {
width: 14px;
height: 14px;
border-radius: 3px;
border: 2px solid #444;
background: transparent;
flex-shrink: 0;
transition: all 0.12s;
}
.varCheck.checked {
border-color: transparent;
}
.varDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.varName {
font-size: 11px;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 图表区域 */
.chartArea {
flex: 1;
min-width: 0;
padding: 16px;
}
/* 空状态 */
.emptyChart {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #555;
font-size: 14px;
}
/* 错误/无数据状态 */
.errorState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
}
.errorIcon {
font-size: 40px;
}
.errorText {
font-size: 16px;
font-weight: 600;
color: #ef4444;
}
.errorHint {
font-size: 12px;
color: #9ca3af;
text-align: center;
max-width: 400px;
line-height: 1.5;
}
/* Recharts 样式覆盖 */
.chartArea :global(.recharts-cartesian-grid-horizontal line),
.chartArea :global(.recharts-cartesian-grid-vertical line) {
stroke: rgba(79, 138, 255, 0.08);
}
/**
* SimResultsPage - 仿真结果图表查看器
* 从 sessionStorage 读取 CSV 数据,解析并以交互式折线图展示
*/
import { useState, useMemo, useCallback } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Brush,
} from 'recharts';
import styles from './SimResultsPage.module.css';
/** 预设调色板 — 高对比度暗色主题友好 */
const COLORS = [
'#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
'#a855f7', '#84cc16', '#e879f9', '#2dd4bf', '#fb923c',
];
/** 解析 CSV 字符串 → [{time, var1, var2, ...}, ...] */
function parseCSV(csv) {
if (!csv || typeof csv !== 'string') return { headers: [], data: [] };
const lines = csv.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return { headers: [], data: [] };
// 表头:可能带引号
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
const data = [];
for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(',');
if (vals.length !== headers.length) continue;
const row = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = parseFloat(vals[j]) || 0;
}
data.push(row);
}
return { headers, data };
}
/** 自定义 Tooltip */
function CustomTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
return (
<div style={{
background: '#1e1e2e',
border: '1px solid #333',
borderRadius: 8,
padding: '10px 14px',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
}}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>
time = {typeof label === 'number' ? label.toFixed(6) : label}
</div>
{payload.map((entry, i) => (
<div key={i} style={{ fontSize: 12, color: entry.color, marginBottom: 2 }}>
{entry.name}: <strong>{typeof entry.value === 'number' ? entry.value.toFixed(6) : entry.value}</strong>
</div>
))}
</div>
);
}
export default function SimResultsPage() {
const [selectedVars, setSelectedVars] = useState(new Set());
const [initialized, setInitialized] = useState(false);
// 从 sessionStorage 读取 CSV
const csvRaw = useMemo(() => {
return sessionStorage.getItem('sim_csv_data') || '';
}, []);
const modelName = useMemo(() => {
return sessionStorage.getItem('sim_model_name') || '未知模型';
}, []);
const { headers, data } = useMemo(() => parseCSV(csvRaw), [csvRaw]);
// 变量列表(排除 time 列)
const variables = useMemo(() => {
const timeKey = headers.find(h => h.toLowerCase() === 'time');
return headers.filter(h => h !== timeKey);
}, [headers]);
// 第一个 header 作为 X 轴(通常是 time)
const xKey = useMemo(() => {
return headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time';
}, [headers]);
// 初始化:默认选中前 3 个变量
useMemo(() => {
if (!initialized && variables.length > 0) {
setSelectedVars(new Set(variables.slice(0, Math.min(3, variables.length))));
setInitialized(true);
}
}, [variables, initialized]);
const toggleVar = useCallback((varName) => {
setSelectedVars(prev => {
const next = new Set(prev);
if (next.has(varName)) next.delete(varName);
else next.add(varName);
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedVars(new Set(variables));
}, [variables]);
const selectNone = useCallback(() => {
setSelectedVars(new Set());
}, []);
// 无数据状态
if (!csvRaw) {
return (
<div className={styles.page}>
<div className={styles.errorState}>
<div className={styles.errorIcon}>⚠️</div>
<div className={styles.errorText}>无仿真数据</div>
<div className={styles.errorHint}>请先在主页面执行仿真,然后点击"查看结果"</div>
<button className={styles.backBtn} onClick={() => window.close()} style={{ marginTop: 16 }}>
← 关闭此页面
</button>
</div>
</div>
);
}
const selectedArr = [...selectedVars];
return (
<div className={styles.page}>
{/* 顶部栏 */}
<div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => window.close()}>
← 关闭
</button>
<span className={styles.pageTitle}>📊 仿真结果</span>
<span className={styles.pageSubtitle}>{modelName}</span>
</div>
<div className={styles.main}>
{/* 左侧变量面板 */}
<div className={styles.varPanel}>
<div className={styles.varHeader}>
<span className={styles.varTitle}>变量 ({variables.length})</span>
<div className={styles.selectActions}>
<button className={styles.selectBtn} onClick={selectAll}>全选</button>
<button className={styles.selectBtn} onClick={selectNone}>清除</button>
</div>
</div>
<div className={styles.varList}>
{variables.map((v, i) => {
const color = COLORS[i % COLORS.length];
const checked = selectedVars.has(v);
return (
<div
key={v}
className={styles.varItem}
onClick={() => toggleVar(v)}
>
<div
className={`${styles.varCheckbox} ${checked ? styles.checked : ''}`}
style={checked ? { background: color, borderColor: color } : {}}
>
{checked && (
<div className={styles.varCheckboxDot} style={{ background: '#fff', borderRadius: 1, width: 6, height: 6 }} />
)}
</div>
<span className={styles.varColorDot} style={{ background: color }} />
<span className={styles.varLabel} title={v}>{v}</span>
</div>
);
})}
</div>
</div>
{/* 图表区域 */}
<div className={styles.chartArea}>
{/* 统计栏 */}
<div className={styles.statsBar}>
<div className={styles.statCard}>
<div className={styles.statLabel}>数据点</div>
<div className={styles.statValue}>{data.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>变量数</div>
<div className={styles.statValue}>{variables.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>已选变量</div>
<div className={styles.statValue}>{selectedArr.length}</div>
</div>
{data.length > 0 && (
<div className={styles.statCard}>
<div className={styles.statLabel}>时间范围</div>
<div className={styles.statValue}>
{data[0][xKey]?.toFixed(2)} ~ {data[data.length - 1][xKey]?.toFixed(2)}
</div>
</div>
)}
</div>
{/* 图表 */}
<div className={styles.chartContainer}>
<div className={styles.chartTitle}>变量随时间变化曲线</div>
{selectedArr.length === 0 ? (
<div className={styles.emptyChart}>
<div className={styles.emptyIcon}>📈</div>
<div className={styles.emptyText}>请在左侧选择要显示的变量</div>
<div className={styles.emptyHint}>勾选变量后,此处将绘制折线图</div>
</div>
) : (
<div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2a3a" />
<XAxis
dataKey={xKey}
stroke="#555"
tick={{ fill: '#888', fontSize: 10 }}
tickFormatter={(v) => typeof v === 'number' ? v.toFixed(2) : v}
/>
<YAxis
stroke="#555"
tick={{ fill: '#888', fontSize: 10 }}
tickFormatter={(v) => typeof v === 'number' ? v.toFixed(2) : v}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: 11, color: '#888' }}
/>
<Brush
dataKey={xKey}
height={24}
stroke="#6366f1"
fill="#16161e"
tickFormatter={(v) => typeof v === 'number' ? v.toFixed(2) : v}
/>
{selectedArr.map((varName, i) => (
<Line
key={varName}
type="monotone"
dataKey={varName}
stroke={COLORS[variables.indexOf(varName) % COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
</div>
</div>
);
}
/* ===== 仿真结果页面 ===== */
.page {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: #121218;
color: #e0e0e0;
font-family: 'Segoe UI', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* 顶部栏 */
.topBar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
height: 48px;
background: #16161e;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
}
.backBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #333;
border-radius: 6px;
background: #1e1e2e;
color: #bbb;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.backBtn:hover {
background: #2a2a3e;
color: #e0e0e0;
border-color: #555;
}
.pageTitle {
font-size: 15px;
font-weight: 700;
color: #e0e0e0;
letter-spacing: 0.5px;
}
.pageSubtitle {
font-size: 12px;
color: #666;
}
/* 主内容区 */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* 左侧变量选择面板 */
.varPanel {
width: 240px;
background: #16161e;
border-right: 1px solid #2a2a3a;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.varHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
}
.varTitle {
font-size: 12px;
font-weight: 700;
color: #bbb;
letter-spacing: 0.5px;
}
.selectActions {
display: flex;
gap: 4px;
}
.selectBtn {
padding: 2px 8px;
border: 1px solid #333;
border-radius: 4px;
background: #1e1e2e;
color: #888;
font-size: 10px;
cursor: pointer;
transition: all 0.12s;
}
.selectBtn:hover {
background: #2a2a3e;
color: #ccc;
border-color: #555;
}
.varList {
flex: 1;
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.varItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.varItem:hover {
background: rgba(255, 255, 255, 0.04);
}
.varCheckbox {
width: 14px;
height: 14px;
border-radius: 3px;
border: 2px solid #444;
background: transparent;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
}
.varCheckbox.checked {
border-color: transparent;
}
.varCheckboxDot {
width: 8px;
height: 8px;
border-radius: 2px;
}
.varLabel {
font-size: 12px;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.varColorDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* 图表区域 */
.chartArea {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
gap: 16px;
}
.chartContainer {
flex: 1;
min-height: 0;
background: #16161e;
border: 1px solid #2a2a3a;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
}
.chartTitle {
font-size: 13px;
font-weight: 700;
color: #bbb;
margin-bottom: 12px;
letter-spacing: 0.5px;
}
.chartWrapper {
flex: 1;
min-height: 0;
}
/* 统计信息 */
.statsBar {
display: flex;
gap: 16px;
flex-shrink: 0;
}
.statCard {
padding: 12px 16px;
background: #16161e;
border: 1px solid #2a2a3a;
border-radius: 8px;
min-width: 120px;
}
.statLabel {
font-size: 10px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.statValue {
font-size: 16px;
font-weight: 700;
color: #e0e0e0;
font-family: 'Consolas', monospace;
}
/* 空状态 */
.emptyChart {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #555;
}
.emptyIcon {
font-size: 48px;
opacity: 0.3;
}
.emptyText {
font-size: 14px;
}
.emptyHint {
font-size: 11px;
color: #444;
}
/* 错误状态 */
.errorState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 12px;
color: #ef4444;
}
.errorIcon {
font-size: 48px;
}
.errorText {
font-size: 16px;
font-weight: 600;
}
.errorHint {
font-size: 12px;
color: #666;
}
/* Recharts 样式覆盖 */
.chartWrapper :global(.recharts-cartesian-grid-horizontal line),
.chartWrapper :global(.recharts-cartesian-grid-vertical line) {
stroke: #2a2a3a;
}
.chartWrapper :global(.recharts-text) {
fill: #888;
font-size: 10px;
}
.chartWrapper :global(.recharts-tooltip-wrapper) {
outline: none;
}
/**
* useProjectStore - 多原理图项目管理 Store
* 支持保存/打开/重命名/删除多个原理图,以及编译/执行状态追踪
* 数据持久化到 localStorage
*/
import { create } from 'zustand';
import useFlowStore from './useFlowStore';
import { compileModel, executeModel } from '../utils/api';
import { exportToModelica } from '../utils/modelicaExporter';
const STORAGE_KEY = 'eplan_projects';
/** 从 localStorage 加载项目列表 */
function loadProjects() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch {
return [];
}
}
/** 保存项目列表到 localStorage */
function persistProjects(projects) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
}
function generateId() {
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
const useProjectStore = create((set, get) => ({
projects: loadProjects(),
activeProjectId: null,
panelOpen: false,
togglePanel: () => set({ panelOpen: !get().panelOpen }),
openPanel: () => set({ panelOpen: true }),
closePanel: () => set({ panelOpen: false }),
/** 保存当前画布到项目(已有 active 则更新,否则新建) */
saveProject: (name) => {
const { nodes, edges } = useFlowStore.getState();
const { projects, activeProjectId } = get();
const now = Date.now();
if (activeProjectId) {
// 更新已有项目
const updated = projects.map(p =>
p.id === activeProjectId
? { ...p, nodes, edges, updatedAt: now, name: name || p.name }
: p
);
persistProjects(updated);
set({ projects: updated });
return activeProjectId;
}
// 新建项目
const projectName = name || `原理图 ${projects.length + 1}`;
const newProject = {
id: generateId(),
name: projectName,
nodes,
edges,
createdAt: now,
updatedAt: now,
modelName: 'Circuit',
compileStatus: 'none',
compileResult: null,
executeStatus: 'none',
executeResult: null,
};
const updated = [newProject, ...projects];
persistProjects(updated);
set({ projects: updated, activeProjectId: newProject.id });
return newProject.id;
},
/** 另存为新项目 */
saveAsProject: (name) => {
const { nodes, edges } = useFlowStore.getState();
const { projects } = get();
const now = Date.now();
const newProject = {
id: generateId(),
name: name || `原理图 ${projects.length + 1}`,
nodes,
edges,
createdAt: now,
updatedAt: now,
modelName: 'Circuit',
compileStatus: 'none',
compileResult: null,
executeStatus: 'none',
executeResult: null,
};
const updated = [newProject, ...projects];
persistProjects(updated);
set({ projects: updated, activeProjectId: newProject.id });
return newProject.id;
},
/** 打开项目,加载数据到画布 */
openProject: (id) => {
const project = get().projects.find(p => p.id === id);
if (!project) return;
const flowStore = useFlowStore.getState();
flowStore.importFromJSON(JSON.stringify({
nodes: project.nodes || [],
edges: project.edges || [],
}));
set({ activeProjectId: id });
},
/** 重命名项目 */
renameProject: (id, name) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, name, updatedAt: Date.now() } : p
);
persistProjects(updated);
set({ projects: updated });
},
/** 删除项目 */
deleteProject: (id) => {
const { projects, activeProjectId } = get();
const updated = projects.filter(p => p.id !== id);
persistProjects(updated);
set({
projects: updated,
activeProjectId: activeProjectId === id ? null : activeProjectId,
});
},
/** 更新项目的 Modelica 模型名 */
updateModelName: (id, modelName) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, modelName } : p
);
persistProjects(updated);
set({ projects: updated });
},
/** 编译项目 — 调用后端 API */
compileProject: async (id) => {
// 编译前先自动保存当前画布数据,确保与画布一致
get().saveProject();
const { projects } = get();
const project = projects.find(p => p.id === id);
if (!project) return;
// 使用当前画布最新数据生成 .mo(与导出按钮一致)
const { nodes, edges } = useFlowStore.getState();
const setStatus = (updates) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, ...updates } : p
);
persistProjects(updated);
set({ projects: updated });
};
setStatus({ compileStatus: 'compiling', compileResult: null });
try {
// 生成 .mo 代码 — 使用画布最新数据
const exported = exportToModelica(
{ nodes, edges },
project.modelName || 'Circuit'
);
if (exported.errors && exported.errors.length > 0) {
setStatus({
compileStatus: 'error',
compileResult: { message: '模型导出失败', errors: exported.errors.join('\n') },
});
return;
}
// 发送到后端编译 — 响应格式: { code, message, data }
const resp = await compileModel(exported.code, project.modelName || 'Circuit');
const isOk = resp.code === 0;
setStatus({
compileStatus: isOk ? 'success' : 'error',
compileResult: {
message: resp.message,
output: resp.data?.compile_output || '',
errors: resp.data?.error_detail || '',
},
// 编译后重置执行状态
executeStatus: 'none',
executeResult: null,
});
} catch (err) {
setStatus({
compileStatus: 'error',
compileResult: { message: '请求失败', errors: err.message },
});
}
},
/** 执行仿真 — 调用后端 API(仅编译成功后可执行) */
executeProject: async (id) => {
const { projects } = get();
const project = projects.find(p => p.id === id);
if (!project || project.compileStatus !== 'success') return;
const setStatus = (updates) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, ...updates } : p
);
persistProjects(updated);
set({ projects: updated });
};
setStatus({ executeStatus: 'running', executeResult: null });
try {
// 响应格式: { code, message, data: { sim_log, csv_data } }
const resp = await executeModel(project.modelName || 'Circuit');
const isOk = resp.code === 0;
setStatus({
executeStatus: isOk ? 'success' : 'error',
executeResult: {
message: resp.message,
logs: resp.data?.sim_log || '',
csvData: resp.data?.csv_data || '',
},
});
} catch (err) {
setStatus({
executeStatus: 'error',
executeResult: { message: '请求失败', logs: err.message },
});
}
},
/** 关闭当前项目(不清画布) */
closeProject: () => set({ activeProjectId: null }),
/** 获取当前活动项目 */
getActiveProject: () => {
const { projects, activeProjectId } = get();
return projects.find(p => p.id === activeProjectId) || null;
},
}));
export default useProjectStore;
/**
* api.js - 后端 API 调用封装
* 通过 Vite 代理转发到后端
*
* 统一响应格式: { code: 0, message: "ok", data: {...} }
* JSON 字段统一使用 snake_case
*/
const API_BASE = '/api';
/**
* 通用请求封装 — 自动解析信封格式
* @returns {Promise<{code: number, message: string, data: object}>}
*/
async function request(url, options = {}) {
const resp = await fetch(`${API_BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const json = await resp.json();
return json;
}
/**
* 编译 Modelica 模型
* @param {string} moCode - .mo 源代码
* @param {string} modelName - 模型名称
* @returns {Promise<{code, message, data: {compile_output, model_name, error_detail?}}>}
*/
export async function compileModel(moCode, modelName) {
return request('/compile', {
method: 'POST',
body: JSON.stringify({ mo_code: moCode, model_name: modelName }),
});
}
/**
* 执行仿真
* @param {string} modelName - 已编译的模型名称
* @returns {Promise<{code, message, data: {sim_log, model_name, csv_data?}}>}
*/
export async function executeModel(modelName) {
return request('/execute', {
method: 'POST',
body: JSON.stringify({ model_name: modelName }),
});
}
/**
* 健康检查
* @returns {Promise<{status: string, version: string}>}
*/
export async function checkHealth() {
const resp = await fetch(`${API_BASE}/health`);
return resp.json();
}
......@@ -177,12 +177,12 @@ export function exportToModelica(data, modelName = 'Circuit') {
const packages = new Set();
// 包含我们通过模型名解析出来的包
usedModelNames.forEach(name => {
// 如果是全限定名 (A.B.C),取顶级包
const dot = name.indexOf('.');
if (dot > 0) {
packages.add(name.substring(0, dot));
} else {
packages.add('Modelica.Electrical.Analog.Basic'); // Fallback to basic electrical
}
// 不再添加默认的 Modelica.Electrical.Analog.Basic
});
// 包含上游直接指定的包 (如果上游代码在别处添加了)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment