Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
E
EPlanVisualizer
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
嵇洲
EPlanVisualizer
Commits
3e05f33d
Commit
3e05f33d
authored
Mar 18, 2026
by
Developer
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 优化项目面板布局和实物节点样式
parent
9b79e64e
Changes
8
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
837 additions
and
42 deletions
+837
-42
App.jsx
src/App.jsx
+15
-1
FlowCanvas.jsx
src/components/Canvas/FlowCanvas.jsx
+9
-0
LiveChart.jsx
src/components/SimResults/LiveChart.jsx
+154
-23
LiveChart.module.css
src/components/SimResults/LiveChart.module.css
+153
-1
useFlowStore.js
src/hooks/useFlowStore.js
+24
-16
HardwarePanel.jsx
src/pages/HardwarePanel.jsx
+243
-0
HardwarePanel.module.css
src/pages/HardwarePanel.module.css
+226
-0
api.js
src/utils/api.js
+13
-1
No files found.
src/App.jsx
View file @
3e05f33d
...
...
@@ -2,7 +2,9 @@
* App - 主入口组件
* 支持两个视图:流程编辑器 / 符号编辑器
* 右侧集成项目管理面板
* 独立路由: /hil-panel — 虚拟硬件操控面板
*/
import
{
BrowserRouter
,
Routes
,
Route
}
from
'react-router-dom'
;
import
{
ReactFlowProvider
}
from
'@xyflow/react'
;
import
Toolbar
from
'./components/Toolbar/Toolbar'
;
import
Sidebar
from
'./components/Sidebar/Sidebar'
;
...
...
@@ -10,10 +12,11 @@ import FlowCanvas from './components/Canvas/FlowCanvas';
import
PropertiesPanel
from
'./components/PropertiesPanel/PropertiesPanel'
;
import
ComponentEditor
from
'./components/ComponentEditor/ComponentEditor'
;
import
ProjectPanel
from
'./components/ProjectPanel/ProjectPanel'
;
import
HardwarePanel
from
'./pages/HardwarePanel'
;
import
useComponentLibrary
from
'./hooks/useComponentLibrary'
;
import
'./App.css'
;
export
default
function
App
()
{
function
Main
App
()
{
const
currentView
=
useComponentLibrary
(
s
=>
s
.
currentView
);
return
(
...
...
@@ -34,3 +37,14 @@ export default function App() {
</
ReactFlowProvider
>
);
}
export
default
function
App
()
{
return
(
<
BrowserRouter
>
<
Routes
>
<
Route
path=
"/hil-panel"
element=
{
<
HardwarePanel
/>
}
/>
<
Route
path=
"*"
element=
{
<
MainApp
/>
}
/>
</
Routes
>
</
BrowserRouter
>
);
}
src/components/Canvas/FlowCanvas.jsx
View file @
3e05f33d
...
...
@@ -219,6 +219,15 @@ export default function FlowCanvas() {
onDoubleClick=
{
onDoubleClick
}
onDragOver=
{
onDragOver
}
onDrop=
{
onDrop
}
onNodesDelete=
{
(
deleted
)
=>
{
// 键盘删除后清理关联边 + 清空选中
const
ids
=
new
Set
(
deleted
.
map
(
n
=>
n
.
id
));
const
{
edges
,
selectedNode
}
=
useFlowStore
.
getState
();
useFlowStore
.
setState
({
edges
:
edges
.
filter
(
e
=>
!
ids
.
has
(
e
.
source
)
&&
!
ids
.
has
(
e
.
target
)),
selectedNode
:
selectedNode
&&
ids
.
has
(
selectedNode
.
id
)
?
null
:
selectedNode
,
});
}
}
nodeTypes=
{
nodeTypes
}
edgeTypes=
{
edgeTypes
}
defaultEdgeOptions=
{
defaultEdgeOptions
}
...
...
src/components/SimResults/LiveChart.jsx
View file @
3e05f33d
/**
* LiveChart — 实时仿真数据图表
* LiveChart — 实时仿真数据图表
+ 硬件操控面板
*
* 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧,
* 动态追加数据点并实时绘制。
* 动态追加数据点并实时绘制。
右侧嵌入硬件操控面板。
*
* Props:
* rosBridgeUrl — rosbridge WebSocket 地址 (默认 ws://localhost:9090)
* rosBridgeUrl — rosbridge WebSocket 地址
* sessionId — HIL 会话 ID(用于获取硬件端口列表)
* onClose — 关闭回调
* onDone — 仿真完成回调
*/
import
{
useState
,
useEffect
,
useMemo
,
useCallback
}
from
'react'
;
import
{
useState
,
useEffect
,
useMemo
,
useCallback
,
useRef
}
from
'react'
;
import
{
LineChart
,
Line
,
XAxis
,
YAxis
,
CartesianGrid
,
Tooltip
,
ResponsiveContainer
,
}
from
'recharts'
;
import
useRosBridge
from
'../../hooks/useRosBridge'
;
import
{
getHilPorts
}
from
'../../utils/api'
;
import
styles
from
'./LiveChart.module.css'
;
const
COLORS
=
[
...
...
@@ -21,7 +24,6 @@ const COLORS = [
'#a855f7'
,
'#ec4899'
,
'#14b8a6'
,
'#f97316'
,
'#64748b'
,
];
// 简易中文映射 (复用 SimResultsModal 的逻辑)
const
COMP_CN
=
{
capacitor
:
'电容'
,
resistor
:
'电阻'
,
inductor
:
'电感'
,
voltagesource
:
'电压源'
,
currentsource
:
'电流源'
,
ground
:
'接地'
,
...
...
@@ -42,18 +44,8 @@ function toChinese(name) {
}
function
StatusDot
({
status
})
{
const
colorMap
=
{
disconnected
:
'#666'
,
connecting
:
'#fbbf24'
,
connected
:
'#22c55e'
,
error
:
'#ef4444'
,
};
const
labelMap
=
{
disconnected
:
'未连接'
,
connecting
:
'连接中…'
,
connected
:
'实时接收中'
,
error
:
'连接失败'
,
};
const
colorMap
=
{
disconnected
:
'#666'
,
connecting
:
'#fbbf24'
,
connected
:
'#22c55e'
,
error
:
'#ef4444'
};
const
labelMap
=
{
disconnected
:
'未连接'
,
connecting
:
'连接中…'
,
connected
:
'实时接收中'
,
error
:
'连接失败'
};
return
(
<
span
className=
{
styles
.
statusDot
}
>
<
span
className=
{
styles
.
dot
}
style=
{
{
background
:
colorMap
[
status
]
||
'#666'
}
}
/>
...
...
@@ -62,24 +54,108 @@ function StatusDot({ status }) {
);
}
export
default
function
LiveChart
({
rosBridgeUrl
,
onClose
,
onDone
})
{
const
{
status
,
headers
,
data
,
connect
,
disconnect
}
=
useRosBridge
({
onDone
});
// ── 硬件控件模式配置 ──
const
MODE_CONFIG
=
{
switch
:
{
label
:
'开关'
,
icon
:
'🔘'
,
unit
:
''
,
min
:
0
,
max
:
1
,
step
:
1
,
isSwitch
:
true
},
resistor
:
{
label
:
'电阻'
,
icon
:
'🎛'
,
unit
:
'Ω'
,
min
:
1
,
max
:
100000
,
step
:
1
,
isSwitch
:
false
},
capacitor
:
{
label
:
'电容'
,
icon
:
'🔋'
,
unit
:
'μF'
,
min
:
0.1
,
max
:
10000
,
step
:
0.1
,
isSwitch
:
false
},
inductor
:
{
label
:
'电感'
,
icon
:
'🧲'
,
unit
:
'mH'
,
min
:
0.1
,
max
:
10000
,
step
:
0.1
,
isSwitch
:
false
},
voltage_src
:
{
label
:
'电压源'
,
icon
:
'⚡'
,
unit
:
'V'
,
min
:
0
,
max
:
1000
,
step
:
0.1
,
isSwitch
:
false
},
};
export
default
function
LiveChart
({
rosBridgeUrl
,
sessionId
,
onClose
,
onDone
})
{
const
[
simDone
,
setSimDone
]
=
useState
(
false
);
const
wrappedOnDone
=
useCallback
(()
=>
{
setSimDone
(
true
);
onDone
?.();
},
[
onDone
]);
const
{
status
,
headers
,
data
,
connect
,
disconnect
}
=
useRosBridge
({
onDone
:
wrappedOnDone
});
const
[
selectedVars
,
setSelectedVars
]
=
useState
(
null
);
// 自动连接
// ── 硬件面板状态 ──
const
[
hwPorts
,
setHwPorts
]
=
useState
([]);
const
[
hwValues
,
setHwValues
]
=
useState
({});
const
hwWsRef
=
useRef
(
null
);
// 自动连接 rosbridge(数据接收)
useEffect
(()
=>
{
connect
(
rosBridgeUrl
||
'ws://localhost:9090'
);
return
()
=>
disconnect
();
},
[
rosBridgeUrl
]);
// eslint-disable-line react-hooks/exhaustive-deps
// 识别变量列 (排除 time)
// ── 获取硬件端口列表 ──
useEffect
(()
=>
{
if
(
!
sessionId
)
return
;
getHilPorts
(
sessionId
).
then
(
resp
=>
{
if
(
resp
.
code
===
0
&&
resp
.
data
?.
ports
)
{
const
p
=
resp
.
data
.
ports
;
setHwPorts
(
p
);
const
init
=
{};
p
.
forEach
(
port
=>
{
const
name
=
port
.
instance_name
||
port
.
name
;
init
[
name
]
=
port
.
mode
===
'switch'
?
0
:
port
.
gain
;
});
setHwValues
(
init
);
}
}).
catch
(()
=>
{});
},
[
sessionId
]);
// ── 硬件面板 WebSocket(发布 override) ──
useEffect
(()
=>
{
if
(
!
hwPorts
.
length
)
return
;
const
ws
=
new
WebSocket
(
`ws://
${
window
.
location
.
hostname
}
:9090`
);
hwWsRef
.
current
=
ws
;
ws
.
onopen
=
()
=>
{
ws
.
send
(
JSON
.
stringify
({
op
:
'advertise'
,
topic
:
'/hil/user_override'
,
type
:
'std_msgs/String'
,
}));
};
ws
.
onclose
=
()
=>
{
if
(
hwWsRef
.
current
===
ws
)
hwWsRef
.
current
=
null
;
};
ws
.
onerror
=
()
=>
{};
return
()
=>
{
if
(
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
op
:
'unadvertise'
,
topic
:
'/hil/user_override'
}));
}
ws
.
close
();
};
},
[
hwPorts
.
length
]);
const
publishOverride
=
useCallback
((
component
,
value
)
=>
{
const
ws
=
hwWsRef
.
current
;
if
(
!
ws
||
ws
.
readyState
!==
WebSocket
.
OPEN
)
return
;
ws
.
send
(
JSON
.
stringify
({
op
:
'publish'
,
topic
:
'/hil/user_override'
,
msg
:
{
data
:
JSON
.
stringify
({
component
,
value
})
},
}));
},
[]);
const
handleHwChange
=
useCallback
((
component
,
value
)
=>
{
const
numVal
=
Number
(
value
);
setHwValues
(
prev
=>
({
...
prev
,
[
component
]:
numVal
}));
publishOverride
(
component
,
numVal
);
},
[
publishOverride
]);
const
handleToggle
=
useCallback
((
component
)
=>
{
setHwValues
(
prev
=>
{
const
newVal
=
prev
[
component
]
>
0.5
?
0
:
1
;
publishOverride
(
component
,
newVal
);
return
{
...
prev
,
[
component
]:
newVal
};
});
},
[
publishOverride
]);
// ── 图表逻辑 ──
const
xKey
=
useMemo
(
()
=>
headers
.
find
(
h
=>
h
.
toLowerCase
()
===
'time'
)
||
headers
[
0
]
||
'time'
,
[
headers
]
);
const
variables
=
useMemo
(()
=>
headers
.
filter
(
h
=>
h
!==
xKey
),
[
headers
,
xKey
]);
// 自动选择前 5 个变量
useEffect
(()
=>
{
if
(
variables
.
length
>
0
&&
selectedVars
===
null
)
{
setSelectedVars
(
new
Set
(
variables
.
slice
(
0
,
Math
.
min
(
5
,
variables
.
length
))));
...
...
@@ -93,7 +169,6 @@ export default function LiveChart({ rosBridgeUrl, onClose, onDone }) {
const
s
=
new
Set
(
prev
);
s
.
has
(
v
)
?
s
.
delete
(
v
)
:
s
.
add
(
v
);
return
s
;
}),
[]);
// 使用最近 500 个点渲染 (避免 SVG 性能问题)
const
displayData
=
useMemo
(()
=>
{
if
(
data
.
length
<=
500
)
return
data
;
return
data
.
slice
(
-
500
);
...
...
@@ -172,6 +247,62 @@ export default function LiveChart({ rosBridgeUrl, onClose, onDone }) {
</
ResponsiveContainer
>
)
}
</
div
>
{
/* 硬件操控面板(右侧) */
}
{
hwPorts
.
length
>
0
&&
!
simDone
&&
(
<
div
className=
{
styles
.
hwPanel
}
>
<
div
className=
{
styles
.
hwTitle
}
>
🎛 硬件操控
</
div
>
<
div
className=
{
styles
.
hwList
}
>
{
hwPorts
.
map
(
port
=>
{
const
name
=
port
.
instance_name
||
port
.
name
;
const
cfg
=
MODE_CONFIG
[
port
.
mode
]
||
MODE_CONFIG
.
resistor
;
const
value
=
hwValues
[
name
]
??
port
.
gain
;
return
(
<
div
key=
{
name
}
className=
{
styles
.
hwCard
}
>
<
div
className=
{
styles
.
hwCardHead
}
>
<
span
>
{
cfg
.
icon
}
</
span
>
<
span
className=
{
styles
.
hwCardName
}
>
{
port
.
hw_label
||
name
}
</
span
>
<
span
className=
{
styles
.
hwCardMode
}
>
{
cfg
.
label
}
</
span
>
</
div
>
{
cfg
.
isSwitch
?
(
<
div
className=
{
styles
.
hwSwitch
}
>
<
button
className=
{
`${styles.hwSwitchBtn} ${value > 0.5 ? styles.hwOn : styles.hwOff}`
}
onClick=
{
()
=>
handleToggle
(
name
)
}
>
<
span
className=
{
styles
.
hwKnob
}
/>
</
button
>
<
span
className=
{
value
>
0.5
?
styles
.
hwLabelOn
:
styles
.
hwLabelOff
}
>
{
value
>
0.5
?
'ON'
:
'OFF'
}
</
span
>
</
div
>
)
:
(
<
div
className=
{
styles
.
hwSlider
}
>
<
input
type=
"range"
min=
{
cfg
.
min
}
max=
{
cfg
.
max
}
step=
{
cfg
.
step
}
value=
{
value
}
onChange=
{
e
=>
handleHwChange
(
name
,
e
.
target
.
value
)
}
className=
{
styles
.
hwRange
}
/>
<
div
className=
{
styles
.
hwValRow
}
>
<
input
type=
"number"
min=
{
cfg
.
min
}
max=
{
cfg
.
max
}
step=
{
cfg
.
step
}
value=
{
value
}
onChange=
{
e
=>
handleHwChange
(
name
,
e
.
target
.
value
)
}
className=
{
styles
.
hwNumInput
}
/>
<
span
className=
{
styles
.
hwUnit
}
>
{
cfg
.
unit
}
</
span
>
</
div
>
</
div
>
)
}
</
div
>
);
})
}
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
...
...
src/components/SimResults/LiveChart.module.css
View file @
3e05f33d
...
...
@@ -14,7 +14,7 @@
.modal
{
width
:
90vw
;
height
:
80vh
;
max-width
:
1
20
0px
;
max-width
:
1
44
0px
;
background
:
#0f0f1a
;
border
:
1px
solid
rgba
(
79
,
138
,
255
,
0.15
);
border-radius
:
16px
;
...
...
@@ -199,3 +199,155 @@
color
:
#555
;
font-size
:
13px
;
}
/* ===== 硬件操控面板(右侧) ===== */
.hwPanel
{
width
:
220px
;
border-left
:
1px
solid
rgba
(
79
,
138
,
255
,
0.08
);
display
:
flex
;
flex-direction
:
column
;
flex-shrink
:
0
;
}
.hwTitle
{
padding
:
10px
12px
;
font-size
:
12px
;
font-weight
:
700
;
color
:
#22c55e
;
border-bottom
:
1px
solid
rgba
(
79
,
138
,
255
,
0.08
);
letter-spacing
:
0.5px
;
}
.hwList
{
flex
:
1
;
overflow-y
:
auto
;
padding
:
8px
;
scrollbar-width
:
thin
;
scrollbar-color
:
#333
transparent
;
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
;
}
.hwCard
{
background
:
rgba
(
15
,
23
,
42
,
0.6
);
border
:
1px
solid
rgba
(
79
,
138
,
255
,
0.1
);
border-radius
:
10px
;
padding
:
10px
;
}
.hwCardHead
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
margin-bottom
:
10px
;
font-size
:
11px
;
}
.hwCardName
{
font-weight
:
600
;
color
:
#ccc
;
flex
:
1
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.hwCardMode
{
font-size
:
10px
;
color
:
#64748b
;
background
:
rgba
(
100
,
116
,
139
,
0.15
);
padding
:
1px
6px
;
border-radius
:
3px
;
}
/* 开关 */
.hwSwitch
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
justify-content
:
center
;
}
.hwSwitchBtn
{
width
:
56px
;
height
:
28px
;
border-radius
:
28px
;
border
:
none
;
cursor
:
pointer
;
position
:
relative
;
transition
:
background
0.3s
;
padding
:
0
;
}
.hwOff
{
background
:
#374151
;
}
.hwOn
{
background
:
#22c55e
;
box-shadow
:
0
0
8px
#22c55e44
;
}
.hwKnob
{
position
:
absolute
;
top
:
3px
;
width
:
22px
;
height
:
22px
;
border-radius
:
50%
;
background
:
white
;
transition
:
left
0.3s
;
box-shadow
:
0
1px
4px
rgba
(
0
,
0
,
0
,
0.3
);
}
.hwOff
.hwKnob
{
left
:
3px
;
}
.hwOn
.hwKnob
{
left
:
31px
;
}
.hwLabelOn
{
font-size
:
13px
;
font-weight
:
700
;
color
:
#22c55e
;
}
.hwLabelOff
{
font-size
:
13px
;
font-weight
:
700
;
color
:
#64748b
;
}
/* 滑条 */
.hwSlider
{
display
:
flex
;
flex-direction
:
column
;
gap
:
6px
;
}
.hwRange
{
width
:
100%
;
height
:
4px
;
-webkit-appearance
:
none
;
appearance
:
none
;
background
:
#1e293b
;
border-radius
:
2px
;
outline
:
none
;
}
.hwRange
::-webkit-slider-thumb
{
-webkit-appearance
:
none
;
width
:
14px
;
height
:
14px
;
border-radius
:
50%
;
background
:
#4f8aff
;
cursor
:
pointer
;
box-shadow
:
0
0
4px
rgba
(
79
,
138
,
255
,
0.4
);
}
.hwValRow
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
justify-content
:
center
;
}
.hwNumInput
{
width
:
80px
;
padding
:
4px
6px
;
background
:
#1e293b
;
border
:
1px
solid
rgba
(
79
,
138
,
255
,
0.2
);
border-radius
:
6px
;
color
:
#e2e8f0
;
font-size
:
12px
;
font-weight
:
600
;
text-align
:
center
;
outline
:
none
;
}
.hwNumInput
:focus
{
border-color
:
#4f8aff
;
}
.hwUnit
{
font-size
:
11px
;
color
:
#64748b
;
}
src/hooks/useFlowStore.js
View file @
3e05f33d
...
...
@@ -7,7 +7,10 @@ import { parseConnectionSheet, toReactFlowData } from '../utils/xlsxParser';
import
{
getLayoutedNodes
}
from
'../utils/layoutEngine'
;
import
{
getDeviceType
,
getDeviceDefinition
,
LAYOUT_DIRECTION
,
PORT_TYPES
,
getPortTypeByFunctionCode
}
from
'../utils/constants'
;
let
nodeCounter
=
0
;
/** 生成唯一节点/端口 ID(时间戳 + 随机后缀,不依赖计数器) */
function
uid
()
{
return
Date
.
now
().
toString
(
36
)
+
Math
.
random
().
toString
(
36
).
slice
(
2
,
6
);
}
const
useFlowStore
=
create
((
set
,
get
)
=>
({
nodes
:
[],
...
...
@@ -76,26 +79,26 @@ const useFlowStore = create((set, get) => ({
/** 手动添加节点 */
addNode
:
(
position
=
{
x
:
100
,
y
:
100
},
functionCode
=
100
)
=>
{
const
counter
=
++
nodeCounter
;
const
tag
=
uid
()
;
const
deviceType
=
getDeviceType
(
functionCode
);
const
deviceDef
=
getDeviceDefinition
(
Number
(
functionCode
));
const
id
=
`
new-device-
${
counter
}
`
;
const
id
=
`
dev-
${
tag
}
`
;
// 使用设备定义的端口和尺寸,或回退到默认值
const
defW
=
deviceDef
?.
width
||
180
;
const
defH
=
deviceDef
?.
height
||
80
;
const
defPorts
=
deviceDef
?.
ports
?
deviceDef
.
ports
.
map
(
p
=>
({
...
p
,
id
:
`
${
p
.
id
}
-
${
counter
}
`
,
// 确保端口 id 全局唯一
portId
:
p
.
id
,
description
:
''
,
connector
:
p
.
connector
||
p
.
name
,
}))
...
p
,
id
:
`
${
p
.
id
}
-
${
tag
}
`
,
// 确保端口 id 全局唯一
portId
:
p
.
id
,
description
:
''
,
connector
:
p
.
connector
||
p
.
name
,
}))
:
[
{
id
:
`p-in-1-
${
counter
}
`
,
name
:
'输入1'
,
portId
:
1
,
description
:
''
,
type
:
'generic'
,
side
:
'left'
,
position
:
0.5
,
connector
:
'in1'
},
{
id
:
`p-out-1-
${
counter
}
`
,
name
:
'输出1'
,
portId
:
2
,
description
:
''
,
type
:
'generic'
,
side
:
'right'
,
position
:
0.5
,
connector
:
'out1'
},
];
{
id
:
`p-in-1-
${
tag
}
`
,
name
:
'输入1'
,
portId
:
1
,
description
:
''
,
type
:
'generic'
,
side
:
'left'
,
position
:
0.5
,
connector
:
'in1'
},
{
id
:
`p-out-1-
${
tag
}
`
,
name
:
'输出1'
,
portId
:
2
,
description
:
''
,
type
:
'generic'
,
side
:
'right'
,
position
:
0.5
,
connector
:
'out1'
},
];
const
templateData
=
{
name
:
deviceType
.
label
,
...
...
@@ -130,8 +133,8 @@ const useFlowStore = create((set, get) => ({
/** 添加自定义模板节点 */
addCustomNode
:
(
position
=
{
x
:
100
,
y
:
100
},
template
)
=>
{
const
counter
=
++
nodeCounter
;
const
id
=
`cust
om-
${
counter
}
`
;
const
tag
=
uid
()
;
const
id
=
`cust
-
${
tag
}
`
;
const
defaultPortType
=
template
.
ports
?.[
0
]?.
type
||
'generic'
;
const
newNode
=
{
id
,
...
...
@@ -243,8 +246,13 @@ const useFlowStore = create((set, get) => ({
importFromJSON
:
(
jsonString
)
=>
{
try
{
const
{
nodes
,
edges
}
=
JSON
.
parse
(
jsonString
);
set
({
nodes
:
nodes
||
[],
edges
:
edges
||
[],
error
:
null
});
const
parsed
=
JSON
.
parse
(
jsonString
);
// 清除 React Flow 内部属性,强制重新计算 handle 位置
const
nodes
=
(
parsed
.
nodes
||
[]).
map
(
n
=>
{
const
{
measured
,
width
,
height
,
internals
,
...
clean
}
=
n
;
return
clean
;
});
set
({
nodes
,
edges
:
parsed
.
edges
||
[],
error
:
null
});
}
catch
(
err
)
{
set
({
error
:
'无效的 JSON 文件'
});
}
...
...
src/pages/HardwarePanel.jsx
0 → 100644
View file @
3e05f33d
/**
* HardwarePanel — 虚拟硬件操控面板 (独立页面)
*
* 路由: /hil-panel?session_id=xxx
* 功能: 从 API 获取当前 HIL 会话的硬件端口列表,
* 为每个实物元器件渲染对应的控件(开关/滑条),
* 操作时通过 rosbridge WebSocket 发布到 /hil/user_override。
*/
import
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
'react'
;
import
{
getHilPorts
}
from
'../utils/api'
;
import
styles
from
'./HardwarePanel.module.css'
;
// 模式配置
const
MODE_CONFIG
=
{
switch
:
{
label
:
'开关'
,
icon
:
'🔘'
,
unit
:
''
,
min
:
0
,
max
:
1
,
step
:
1
,
isSwitch
:
true
},
resistor
:
{
label
:
'电阻'
,
icon
:
'🎛'
,
unit
:
'Ω'
,
min
:
1
,
max
:
100000
,
step
:
1
,
isSwitch
:
false
},
capacitor
:
{
label
:
'电容'
,
icon
:
'🔋'
,
unit
:
'μF'
,
min
:
0.1
,
max
:
10000
,
step
:
0.1
,
isSwitch
:
false
},
inductor
:
{
label
:
'电感'
,
icon
:
'🧲'
,
unit
:
'mH'
,
min
:
0.1
,
max
:
10000
,
step
:
0.1
,
isSwitch
:
false
},
voltage_src
:
{
label
:
'电压源'
,
icon
:
'⚡'
,
unit
:
'V'
,
min
:
0
,
max
:
1000
,
step
:
0.1
,
isSwitch
:
false
},
};
function
formatValue
(
value
,
mode
)
{
const
cfg
=
MODE_CONFIG
[
mode
];
if
(
!
cfg
)
return
`
${
value
}
`
;
if
(
cfg
.
isSwitch
)
return
value
>
0.5
?
'ON'
:
'OFF'
;
return
`
${
Number
(
value
).
toFixed
(
2
)}
${
cfg
.
unit
}
`
;
}
export
default
function
HardwarePanel
()
{
const
[
ports
,
setPorts
]
=
useState
([]);
const
[
values
,
setValues
]
=
useState
({});
const
[
wsStatus
,
setWsStatus
]
=
useState
(
'disconnected'
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
null
);
const
wsRef
=
useRef
(
null
);
// 从 URL 获取 session_id
const
sessionId
=
new
URLSearchParams
(
window
.
location
.
search
).
get
(
'session_id'
);
// 获取硬件端口列表
useEffect
(()
=>
{
if
(
!
sessionId
)
{
setError
(
'缺少 session_id 参数'
);
setLoading
(
false
);
return
;
}
getHilPorts
(
sessionId
).
then
(
resp
=>
{
if
(
resp
.
code
===
0
&&
resp
.
data
?.
ports
)
{
const
p
=
resp
.
data
.
ports
;
setPorts
(
p
);
// 初始值 = gain
const
initVals
=
{};
p
.
forEach
(
port
=>
{
const
name
=
port
.
instance_name
||
port
.
name
;
initVals
[
name
]
=
port
.
mode
===
'switch'
?
0
:
port
.
gain
;
});
setValues
(
initVals
);
}
else
{
setError
(
resp
.
message
||
'获取端口列表失败'
);
}
setLoading
(
false
);
}).
catch
(
err
=>
{
setError
(
'网络错误: '
+
err
.
message
);
setLoading
(
false
);
});
},
[
sessionId
]);
// 连接 rosbridge WebSocket
useEffect
(()
=>
{
const
url
=
`ws://
${
window
.
location
.
hostname
}
:9090`
;
console
.
log
(
'[HardwarePanel] 连接 WebSocket:'
,
url
);
setWsStatus
(
'connecting'
);
const
ws
=
new
WebSocket
(
url
);
wsRef
.
current
=
ws
;
ws
.
onopen
=
()
=>
{
console
.
log
(
'[HardwarePanel] WebSocket 已连接'
);
setWsStatus
(
'connected'
);
// 必须先 advertise 话题,rosbridge 才能转发 publish
ws
.
send
(
JSON
.
stringify
({
op
:
'advertise'
,
topic
:
'/hil/user_override'
,
type
:
'std_msgs/String'
,
}));
console
.
log
(
'[HardwarePanel] 已 advertise /hil/user_override'
);
};
ws
.
onerror
=
(
e
)
=>
{
console
.
error
(
'[HardwarePanel] WebSocket 错误:'
,
e
);
setWsStatus
(
'error'
);
};
ws
.
onclose
=
(
e
)
=>
{
console
.
warn
(
'[HardwarePanel] WebSocket 关闭:'
,
e
.
code
,
e
.
reason
);
// 仅清理自己的引用,防止 StrictMode 双挂载覆盖新连接
if
(
wsRef
.
current
===
ws
)
{
setWsStatus
(
'disconnected'
);
wsRef
.
current
=
null
;
}
};
return
()
=>
{
// 断开前 unadvertise
if
(
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
op
:
'unadvertise'
,
topic
:
'/hil/user_override'
}));
}
ws
.
close
();
};
},
[]);
// 发布用户操控值
const
publishOverride
=
useCallback
((
component
,
value
)
=>
{
const
ws
=
wsRef
.
current
;
if
(
!
ws
||
ws
.
readyState
!==
WebSocket
.
OPEN
)
{
console
.
warn
(
'[HardwarePanel] WebSocket 未连接,无法发布'
,
ws
?.
readyState
);
return
;
}
const
payload
=
JSON
.
stringify
({
component
,
value
});
console
.
log
(
'[HardwarePanel] 发布 override:'
,
payload
);
// rosbridge publish 协议
ws
.
send
(
JSON
.
stringify
({
op
:
'publish'
,
topic
:
'/hil/user_override'
,
msg
:
{
data
:
payload
},
}));
},
[]);
// 更新值并发布
const
handleChange
=
useCallback
((
component
,
value
,
mode
)
=>
{
const
numVal
=
Number
(
value
);
setValues
(
prev
=>
({
...
prev
,
[
component
]:
numVal
}));
publishOverride
(
component
,
numVal
);
},
[
publishOverride
]);
// 开关切换
const
handleToggle
=
useCallback
((
component
)
=>
{
setValues
(
prev
=>
{
const
newVal
=
prev
[
component
]
>
0.5
?
0
:
1
;
publishOverride
(
component
,
newVal
);
return
{
...
prev
,
[
component
]:
newVal
};
});
},
[
publishOverride
]);
if
(
loading
)
{
return
(
<
div
className=
{
styles
.
page
}
>
<
div
className=
{
styles
.
loading
}
>
加载中...
</
div
>
</
div
>
);
}
if
(
error
)
{
return
(
<
div
className=
{
styles
.
page
}
>
<
div
className=
{
styles
.
error
}
>
❌
{
error
}
</
div
>
</
div
>
);
}
return
(
<
div
className=
{
styles
.
page
}
>
<
header
className=
{
styles
.
header
}
>
<
h1
className=
{
styles
.
title
}
>
🎛 虚拟硬件面板
</
h1
>
<
div
className=
{
styles
.
headerInfo
}
>
<
span
className=
{
styles
.
sessionBadge
}
>
会话:
{
sessionId
?.
slice
(
0
,
12
)
}
...
</
span
>
<
span
className=
{
`${styles.wsDot} ${styles[wsStatus]}`
}
/>
<
span
className=
{
styles
.
wsLabel
}
>
{
wsStatus
===
'connected'
?
'已连接'
:
wsStatus
===
'connecting'
?
'连接中...'
:
'未连接'
}
</
span
>
</
div
>
</
header
>
<
div
className=
{
styles
.
grid
}
>
{
ports
.
map
(
port
=>
{
const
name
=
port
.
instance_name
||
port
.
name
;
const
cfg
=
MODE_CONFIG
[
port
.
mode
]
||
MODE_CONFIG
.
resistor
;
const
value
=
values
[
name
]
??
port
.
gain
;
return
(
<
div
key=
{
name
}
className=
{
styles
.
card
}
>
<
div
className=
{
styles
.
cardHeader
}
>
<
span
className=
{
styles
.
cardIcon
}
>
{
cfg
.
icon
}
</
span
>
<
span
className=
{
styles
.
cardName
}
>
{
port
.
hw_label
||
name
}
</
span
>
<
span
className=
{
styles
.
cardMode
}
>
{
cfg
.
label
}
</
span
>
</
div
>
<
div
className=
{
styles
.
cardBody
}
>
{
cfg
.
isSwitch
?
(
/* 开关控件 */
<
div
className=
{
styles
.
switchWrapper
}
>
<
button
className=
{
`${styles.switchBtn} ${value > 0.5 ? styles.switchOn : styles.switchOff}`
}
onClick=
{
()
=>
handleToggle
(
name
)
}
>
<
span
className=
{
styles
.
switchKnob
}
/>
</
button
>
<
span
className=
{
`${styles.switchLabel} ${value > 0.5 ? styles.on : styles.off}`
}
>
{
value
>
0.5
?
'ON'
:
'OFF'
}
</
span
>
</
div
>
)
:
(
/* 滑条 + 数字输入 */
<
div
className=
{
styles
.
sliderWrapper
}
>
<
input
type=
"range"
className=
{
styles
.
slider
}
min=
{
cfg
.
min
}
max=
{
cfg
.
max
}
step=
{
cfg
.
step
}
value=
{
value
}
onChange=
{
e
=>
handleChange
(
name
,
e
.
target
.
value
,
port
.
mode
)
}
/>
<
div
className=
{
styles
.
valueRow
}
>
<
input
type=
"number"
className=
{
styles
.
numberInput
}
min=
{
cfg
.
min
}
max=
{
cfg
.
max
}
step=
{
cfg
.
step
}
value=
{
value
}
onChange=
{
e
=>
handleChange
(
name
,
e
.
target
.
value
,
port
.
mode
)
}
/>
<
span
className=
{
styles
.
unit
}
>
{
cfg
.
unit
}
</
span
>
</
div
>
</
div
>
)
}
</
div
>
<
div
className=
{
styles
.
cardFooter
}
>
<
span
className=
{
styles
.
currentValue
}
>
当前:
{
formatValue
(
value
,
port
.
mode
)
}
</
span
>
</
div
>
</
div
>
);
})
}
</
div
>
{
ports
.
length
===
0
&&
(
<
div
className=
{
styles
.
empty
}
>
当前会话没有标记为实物的元器件
</
div
>
)
}
</
div
>
);
}
src/pages/HardwarePanel.module.css
0 → 100644
View file @
3e05f33d
/* HardwarePanel — 虚拟硬件面板暗色主题样式 */
.page
{
min-height
:
100vh
;
background
:
linear-gradient
(
135deg
,
#0a0e1a
0%
,
#111827
50%
,
#0f172a
100%
);
color
:
#e2e8f0
;
font-family
:
'Inter'
,
'Segoe UI'
,
system-ui
,
sans-serif
;
padding
:
24px
;
}
.loading
,
.error
,
.empty
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
min-height
:
60vh
;
font-size
:
18px
;
color
:
#94a3b8
;
}
.error
{
color
:
#f87171
;
}
/* ─── 头部 ─── */
.header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin-bottom
:
32px
;
padding-bottom
:
16px
;
border-bottom
:
1px
solid
rgba
(
79
,
138
,
255
,
0.15
);
}
.title
{
font-size
:
24px
;
font-weight
:
700
;
margin
:
0
;
background
:
linear-gradient
(
135deg
,
#4f8aff
,
#22c55e
);
-webkit-background-clip
:
text
;
-webkit-text-fill-color
:
transparent
;
}
.headerInfo
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.sessionBadge
{
background
:
rgba
(
79
,
138
,
255
,
0.1
);
border
:
1px
solid
rgba
(
79
,
138
,
255
,
0.2
);
border-radius
:
6px
;
padding
:
4px
10px
;
font-size
:
12px
;
color
:
#94a3b8
;
font-family
:
monospace
;
}
.wsDot
{
width
:
8px
;
height
:
8px
;
border-radius
:
50%
;
background
:
#666
;
}
.wsDot.connected
{
background
:
#22c55e
;
box-shadow
:
0
0
6px
#22c55e88
;
}
.wsDot.connecting
{
background
:
#fbbf24
;
}
.wsDot.error
{
background
:
#ef4444
;
}
.wsLabel
{
font-size
:
12px
;
color
:
#94a3b8
;
}
/* ─── 网格 ─── */
.grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
auto-fill
,
minmax
(
300px
,
1
fr
));
gap
:
20px
;
}
/* ─── 卡片 ─── */
.card
{
background
:
rgba
(
15
,
23
,
42
,
0.8
);
border
:
1px
solid
rgba
(
79
,
138
,
255
,
0.15
);
border-radius
:
16px
;
padding
:
20px
;
transition
:
border-color
0.2s
,
box-shadow
0.2s
;
}
.card
:hover
{
border-color
:
rgba
(
79
,
138
,
255
,
0.35
);
box-shadow
:
0
4px
20px
rgba
(
79
,
138
,
255
,
0.08
);
}
.cardHeader
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
margin-bottom
:
16px
;
}
.cardIcon
{
font-size
:
24px
;
}
.cardName
{
font-size
:
16px
;
font-weight
:
600
;
flex
:
1
;
}
.cardMode
{
font-size
:
11px
;
color
:
#64748b
;
background
:
rgba
(
100
,
116
,
139
,
0.15
);
padding
:
2px
8px
;
border-radius
:
4px
;
}
.cardBody
{
margin
:
16px
0
;
}
.cardFooter
{
border-top
:
1px
solid
rgba
(
79
,
138
,
255
,
0.08
);
padding-top
:
12px
;
}
.currentValue
{
font-size
:
12px
;
color
:
#64748b
;
}
/* ─── 开关 ─── */
.switchWrapper
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
justify-content
:
center
;
padding
:
12px
0
;
}
.switchBtn
{
width
:
80px
;
height
:
40px
;
border-radius
:
40px
;
border
:
none
;
cursor
:
pointer
;
position
:
relative
;
transition
:
background
0.3s
;
padding
:
0
;
}
.switchOff
{
background
:
#374151
;
}
.switchOn
{
background
:
#22c55e
;
box-shadow
:
0
0
12px
#22c55e44
;
}
.switchKnob
{
position
:
absolute
;
top
:
4px
;
width
:
32px
;
height
:
32px
;
border-radius
:
50%
;
background
:
white
;
transition
:
left
0.3s
;
box-shadow
:
0
2px
6px
rgba
(
0
,
0
,
0
,
0.3
);
}
.switchOff
.switchKnob
{
left
:
4px
;
}
.switchOn
.switchKnob
{
left
:
44px
;
}
.switchLabel
{
font-size
:
18px
;
font-weight
:
700
;
width
:
40px
;
}
.switchLabel.on
{
color
:
#22c55e
;
}
.switchLabel.off
{
color
:
#64748b
;
}
/* ─── 滑条 ─── */
.sliderWrapper
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
}
.slider
{
width
:
100%
;
height
:
6px
;
-webkit-appearance
:
none
;
appearance
:
none
;
background
:
#1e293b
;
border-radius
:
3px
;
outline
:
none
;
}
.slider
::-webkit-slider-thumb
{
-webkit-appearance
:
none
;
width
:
20px
;
height
:
20px
;
border-radius
:
50%
;
background
:
#4f8aff
;
cursor
:
pointer
;
box-shadow
:
0
0
8px
rgba
(
79
,
138
,
255
,
0.4
);
}
.slider
::-moz-range-thumb
{
width
:
20px
;
height
:
20px
;
border-radius
:
50%
;
background
:
#4f8aff
;
cursor
:
pointer
;
border
:
none
;
}
.valueRow
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
justify-content
:
center
;
}
.numberInput
{
width
:
120px
;
padding
:
8px
12px
;
background
:
#1e293b
;
border
:
1px
solid
rgba
(
79
,
138
,
255
,
0.2
);
border-radius
:
8px
;
color
:
#e2e8f0
;
font-size
:
16px
;
font-weight
:
600
;
text-align
:
center
;
outline
:
none
;
}
.numberInput
:focus
{
border-color
:
#4f8aff
;
box-shadow
:
0
0
0
2px
rgba
(
79
,
138
,
255
,
0.15
);
}
.unit
{
font-size
:
14px
;
color
:
#64748b
;
min-width
:
30px
;
}
src/utils/api.js
View file @
3e05f33d
...
...
@@ -67,12 +67,13 @@ export async function checkHealth() {
* @param {number} params.duration - 仿真时长 (秒)
* @returns {Promise<{code, message, data: {session_id, status, fmu_path, ports}}>}
*/
export
async
function
startHilSession
({
moCode
,
modelName
,
hardwarePorts
,
stepSize
=
0.001
,
duration
=
10.0
})
{
export
async
function
startHilSession
({
moCode
,
modelName
,
fmuPath
=
''
,
hardwarePorts
,
stepSize
=
0.001
,
duration
=
10.0
})
{
return
request
(
'/hil/start'
,
{
method
:
'POST'
,
body
:
JSON
.
stringify
({
mo_code
:
moCode
,
model_name
:
modelName
,
fmu_path
:
fmuPath
,
hardware_ports
:
hardwarePorts
.
map
(
p
=>
({
name
:
p
.
name
,
instance_name
:
p
.
instanceName
,
...
...
@@ -84,6 +85,7 @@ export async function startHilSession({ moCode, modelName, hardwarePorts, stepSi
topic
:
p
.
topic
||
''
,
hw_label
:
p
.
hwNodeLabel
||
''
,
hw_node_id
:
p
.
hwNodeId
||
''
,
gain
:
p
.
gain
??
1.0
,
})),
step_size
:
stepSize
,
duration
,
...
...
@@ -122,3 +124,13 @@ export async function getHilResults(sessionId) {
const
resp
=
await
fetch
(
`
${
API_BASE
}
/hil/results?session_id=
${
encodeURIComponent
(
sessionId
)}
`
);
return
resp
.
json
();
}
/**
* 获取半实物仿真硬件端口列表
* @param {string} sessionId
* @returns {Promise<{code, message, data: {session_id, ports: [{name, instance_name, mode, gain, hw_label}]}}>}
*/
export
async
function
getHilPorts
(
sessionId
)
{
const
resp
=
await
fetch
(
`
${
API_BASE
}
/hil/ports?session_id=
${
encodeURIComponent
(
sessionId
)}
`
);
return
resp
.
json
();
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment