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/`
This diff is collapsed.
This diff is collapsed.
/* ===== 项目面板容器 ===== */
.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