mirror of
https://gitee.com/dlmu-cone/Arborflow
synced 2026-10-24 02:02:19 +08:00
feat: subtree navigation, i18n cleanup, UI polish (centered node text, smooth theme transition, remapped icons)
This commit is contained in:
@@ -35,10 +35,7 @@ export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
|
||||
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
|
||||
{/* Top handle — skip for Root node since nothing connects above it */}
|
||||
{!isRoot && (
|
||||
<>
|
||||
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
||||
<span className="handle-label handle-label-in">IN</span>
|
||||
</>
|
||||
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
||||
)}
|
||||
|
||||
<div className="node-accent" />
|
||||
@@ -87,12 +84,9 @@ export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
|
||||
{/* Root badge */}
|
||||
{isRoot && <span className="runtime-badge" style={{ color: 'var(--amber)', borderColor: 'var(--amber)', bottom: 'auto', top: 6, right: 7 }}>ROOT</span>}
|
||||
|
||||
{/* Bottom handle with OUT label — only if node can have children */}
|
||||
{/* Bottom handle — only if node can have children */}
|
||||
{canHaveChildren(data.nodeType) && (
|
||||
<>
|
||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||
<span className="handle-label handle-label-out">OUT</span>
|
||||
</>
|
||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
|
||||
import { AlertCircle, Braces, ExternalLink, Plus, Trash2, X } from 'lucide-react'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
|
||||
|
||||
@@ -10,9 +10,10 @@ interface InspectorProps {
|
||||
onUpdateNode: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onSelectIssue: (nodeId: string) => void
|
||||
onNavigateToTree: (treeId: string) => void
|
||||
}
|
||||
|
||||
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) {
|
||||
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue, onNavigateToTree }: InspectorProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const updatePort = (oldKey: string, key: string, value: string) => {
|
||||
@@ -94,6 +95,11 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
|
||||
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedNode.data.nodeType === 'SubTree' && selectedNode.data.registrationName && (
|
||||
<button className="secondary-button" style={{ width: '100%', marginBottom: 10 }} onClick={() => onNavigateToTree(selectedNode.data.registrationName)}>
|
||||
<ExternalLink size={14} /> {t('inspector.enterSubtree')}
|
||||
</button>
|
||||
)}
|
||||
<div className="section-divider" />
|
||||
<div className="section-title-row">
|
||||
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Activity, CircleStop, PlugZap, RotateCw, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { matchRuntimeNodes, parseMonitorMessage } from '../lib/runtime'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { BehaviorNode, RuntimeEvent, RuntimeStatus } from '../types'
|
||||
|
||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
@@ -13,11 +14,11 @@ interface MonitorPanelProps {
|
||||
onClearStatuses: () => void
|
||||
}
|
||||
|
||||
const STATE_LABEL: Record<ConnectionState, string> = {
|
||||
disconnected: '未连接', connecting: '连接中', connected: '已连接', error: '连接错误',
|
||||
}
|
||||
|
||||
export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearStatuses }: MonitorPanelProps) {
|
||||
const { t, lang } = useI18n()
|
||||
const STATE_LABEL: Record<ConnectionState, string> = {
|
||||
disconnected: t('monitor.disconnected'), connecting: t('monitor.connecting'), connected: t('monitor.connected'), error: t('monitor.error'),
|
||||
}
|
||||
const [endpoint, setEndpoint] = useState(() => localStorage.getItem('arborflow.monitor.endpoint') || 'ws://127.0.0.1:1667')
|
||||
const [rosTopic, setRosTopic] = useState(() => localStorage.getItem('arborflow.monitor.topic') || '/arborflow/status')
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||
@@ -120,20 +121,20 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
|
||||
<div className="monitor-header">
|
||||
<div className="monitor-title"><Activity size={17} /><strong>Real-time Monitor</strong><span className={`connection-pill ${connectionState}`}><i />{STATE_LABEL[connectionState]}</span></div>
|
||||
<div className="monitor-actions">
|
||||
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title="清空运行状态"><Trash2 size={15} /></button>
|
||||
<button className="icon-button compact" onClick={onClose} title="关闭监视器"><X size={16} /></button>
|
||||
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title={t('monitor.clearRuntime')}><Trash2 size={15} /></button>
|
||||
<button className="icon-button compact" onClick={onClose} title={t('monitor.closeMonitor')}><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="monitor-body">
|
||||
<div className="monitor-connect">
|
||||
<div className="field-group">
|
||||
<label htmlFor="monitor-endpoint">ROS WebSocket 地址</label>
|
||||
<label htmlFor="monitor-endpoint">{t('monitor.endpoint')}</label>
|
||||
<div className="endpoint-row">
|
||||
<input id="monitor-endpoint" className="mono" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} disabled={shouldConnect} />
|
||||
{shouldConnect ? (
|
||||
<button className="secondary-button" onClick={disconnect}><CircleStop size={15} />断开</button>
|
||||
<button className="secondary-button" onClick={disconnect}><CircleStop size={15} />{t('monitor.disconnect')}</button>
|
||||
) : (
|
||||
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />连接</button>
|
||||
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />{t('monitor.connect')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,29 +143,29 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
|
||||
<input id="monitor-topic" className="mono" value={rosTopic} onChange={(event) => setRosTopic(event.target.value)} disabled={shouldConnect} />
|
||||
</div>
|
||||
<label className="toggle-row compact-toggle">
|
||||
<span><span className="toggle-title">自动重连</span></span>
|
||||
<span><span className="toggle-title">{t('monitor.autoReconnect')}</span></span>
|
||||
<input type="checkbox" checked={autoReconnect} onChange={(event) => setAutoReconnect(event.target.checked)} />
|
||||
</label>
|
||||
<div className="monitor-metrics">
|
||||
<div><span>消息</span><strong>{messageCount}</strong></div>
|
||||
<div><span>延迟</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
|
||||
<div><span>匹配</span><strong>{events.filter((event) => event.matchedNodeIds.length).length}</strong></div>
|
||||
<div><span>{t('monitor.messages')}</span><strong>{messageCount}</strong></div>
|
||||
<div><span>{t('monitor.latency')}</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
|
||||
<div><span>{t('monitor.matched')}</span><strong>{events.filter((event) => event.matchedNodeIds.length).length}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-legend">
|
||||
{(['RUNNING', 'SUCCESS', 'FAILURE', 'IDLE', 'SKIPPED'] as RuntimeStatus[]).map((status) => <span key={status}><i className={`runtime-${status.toLowerCase()}`} />{status}</span>)}
|
||||
</div>
|
||||
<div className="event-stream">
|
||||
<div className="event-stream-heading"><span>事件流</span>{connectionState === 'connecting' && <RotateCw className="spin" size={14} />}</div>
|
||||
<div className="event-stream-heading"><span>{t('monitor.eventStream')}</span>{connectionState === 'connecting' && <RotateCw className="spin" size={14} />}</div>
|
||||
<div className="event-list">
|
||||
{!events.length && <div className="event-empty">等待节点状态</div>}
|
||||
{!events.length && <div className="event-empty">{t('monitor.waiting')}</div>}
|
||||
{events.map((event) => (
|
||||
<div className="event-row" key={event.id}>
|
||||
<time>{new Date(event.at).toLocaleTimeString('zh-CN', { hour12: false })}</time>
|
||||
<time>{new Date(event.at).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en-US', { hour12: false })}</time>
|
||||
<i className={`runtime-${event.status.toLowerCase()}`} />
|
||||
<span className="event-node" title={event.nodeRef}>{event.nodeRef}</span>
|
||||
<span className={`event-status runtime-${event.status.toLowerCase()}`}>{event.status}</span>
|
||||
{!event.matchedNodeIds.length && <span className="unmatched">未匹配</span>}
|
||||
{!event.matchedNodeIds.length && <span className="unmatched">{t('monitor.unmatched')}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface NodeDetailModalProps {
|
||||
}
|
||||
|
||||
export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) {
|
||||
const { t } = useI18n()
|
||||
const { t, lang } = useI18n()
|
||||
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
|
||||
|
||||
// Local working copy
|
||||
@@ -100,7 +100,7 @@ export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailM
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
<span style={{ color: 'var(--category-color)' }}>
|
||||
{definition?.labelZh && definition.labelZh !== definition.label ? definition.labelZh : definition?.label}
|
||||
{lang === 'zh' && definition?.labelZh ? definition.labelZh : definition?.label}
|
||||
</span>
|
||||
{t('modal.editNode')}
|
||||
</h2>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
||||
<aside className="left-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">LIBRARY</span>
|
||||
<span className="eyebrow">{t('palette.eyebrow')}</span>
|
||||
<h2>{t('palette.title')}</h2>
|
||||
</div>
|
||||
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
|
||||
@@ -101,7 +101,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
||||
<div className="palette-search">
|
||||
<Search size={16} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('palette.searchPlaceholder')} aria-label={t('palette.searchPlaceholder')} />
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title={t('palette.clearSearch')}><X size={15} /></button>}
|
||||
</div>
|
||||
<div className="palette-scroll">
|
||||
{visibleModels.length > 0 && (
|
||||
@@ -117,7 +117,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
||||
key={model.id}
|
||||
onDragStart={(event) => beginCustomDrag(event, model.id)}
|
||||
onClick={() => onAddCustomNode(model.id)}
|
||||
title={`${catLabel(model.category)} · ${model.ports.length} 个端口`}
|
||||
title={`${catLabel(model.category)} · ${t('palette.portCount').replace('{count}', String(model.ports.length))}`}
|
||||
>
|
||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||
<span className="palette-item-name">{model.id}</span>
|
||||
@@ -170,7 +170,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
||||
key={item.type}
|
||||
onDragStart={(event) => beginDrag(event, item.type)}
|
||||
onClick={() => onAddNode(item.type)}
|
||||
title={nodeDesc(item)}
|
||||
title={`${nodeLabel(item)} — ${nodeDesc(item)}`}
|
||||
>
|
||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||
<span className="palette-item-name">{nodeLabel(item)}</span>
|
||||
|
||||
Reference in New Issue
Block a user