feat: dark/light theme, i18n (zh/en), inline node params with IN/OUT, root node, custom node creation, minimap & light mode contrast fixes

This commit is contained in:
2026-08-04 01:44:52 +08:00
parent f567d05a05
commit 2bdd58108a
17 changed files with 1936 additions and 233 deletions

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111418" />
<link rel="icon" type="image/svg+xml" href="/arborflow.svg" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<title>ArborFlow</title>
</head>
<body>

View File

@@ -26,8 +26,11 @@ import {
FolderOpen,
GitBranch,
MonitorDot,
Moon,
Redo2,
Save,
Settings,
Sun,
TreePine,
Undo2,
Upload,
@@ -36,6 +39,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BehaviorNodeCard } from './components/BehaviorNodeCard'
import { Inspector } from './components/Inspector'
import { MonitorPanel } from './components/MonitorPanel'
import { NodeDetailModal } from './components/NodeDetailModal'
import { NodePalette } from './components/NodePalette'
import { createNode, createNodeFromModel, maxChildren } from './lib/catalog'
import { createDemoDocument, createEmptyDocument } from './lib/demo'
@@ -44,7 +48,9 @@ import { downloadText, parseProject, pickBrowserFile, serializeProject } from '.
import { activeTreeId, switchDocumentTree, syncedTreeGraphs } from './lib/trees'
import { validateDocument, wouldCreateCycle } from './lib/validation'
import { documentToXml, xmlToDocument } from './lib/xml'
import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types'
import { useTheme } from './context/ThemeContext'
import { useI18n } from './context/I18nContext'
import type { BehaviorNode, BehaviorTreeDocument, CustomNodeModel, NodeType, RuntimeStatus } from './types'
const nodeTypes = { behavior: BehaviorNodeCard }
const PROJECT_FILTERS = [{ name: 'ArborFlow Project', extensions: ['arborflow', 'json'] }]
@@ -54,14 +60,18 @@ type ViewMode = 'canvas' | 'xml'
type DocumentUpdater = BehaviorTreeDocument | ((current: BehaviorTreeDocument) => BehaviorTreeDocument)
function Editor() {
const { theme, toggleTheme } = useTheme()
const { t, lang, setLang } = useI18n()
const [treeDocument, setTreeDocument] = useState<BehaviorTreeDocument>(() => createDemoDocument())
const [projectPath, setProjectPath] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('canvas')
const [treeMenuOpen, setTreeMenuOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [monitorOpen, setMonitorOpen] = useState(false)
const [modalNodeId, setModalNodeId] = useState<string | null>(null)
const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, RuntimeStatus>>({})
const [toast, setToast] = useState<{ kind: 'success' | 'error'; message: string } | null>(null)
const [historyRevision, setHistoryRevision] = useState(0)
@@ -163,6 +173,13 @@ function Editor() {
setViewMode('canvas')
}, [commit, screenToFlowPosition, treeDocument.nodeModels])
const addCustomModel = useCallback((model: CustomNodeModel) => {
commit((current) => ({
...current,
nodeModels: [...(current.nodeModels || []), model],
}))
}, [commit])
const switchTree = useCallback((treeId: string) => {
if (treeId === currentTreeId) {
setTreeMenuOpen(false)
@@ -201,9 +218,7 @@ function Editor() {
}, [commit])
const onNodesChange = useCallback((changes: NodeChange<BehaviorNode>[]) => {
const positionChanges = changes.filter((change) => change.type === 'position')
if (!positionChanges.length) return
setTreeDocument((current) => ({ ...current, nodes: applyNodeChanges(positionChanges, current.nodes) }))
setTreeDocument((current) => ({ ...current, nodes: applyNodeChanges(changes, current.nodes) }))
}, [])
const connectNodes: OnConnect = useCallback((connection: Connection) => {
@@ -445,10 +460,32 @@ function Editor() {
<span className="toolbar-separator" />
<button className="icon-button" onClick={importXml} title="导入 XML"><Upload size={17} /></button>
<button className="icon-button" onClick={exportXml} title="导出 XML"><FileDown size={17} /></button>
<span className="toolbar-separator" />
<button className={`icon-button ${settingsOpen ? 'active' : ''}`} onClick={() => setSettingsOpen((v) => !v)} title="设置"><Settings size={17} /></button>
<button className={`monitor-button ${monitorOpen ? 'active' : ''}`} onClick={() => setMonitorOpen((value) => !value)}><MonitorDot size={16} />Monitor</button>
</div>
</header>
{settingsOpen && (
<div className="settings-dropdown">
<div className="settings-heading">{t('settings.title')}</div>
<div className="settings-heading" style={{ marginTop: 4 }}>{t('settings.theme')}</div>
<button className="settings-row" onClick={toggleTheme}>
<span>{theme === 'dark' ? <><Moon size={14} /> {t('settings.dark')}</> : <><Sun size={14} /> {t('settings.light')}</>}</span>
<small>{theme === 'dark' ? 'Dark' : 'Light'}</small>
</button>
<div className="settings-heading" style={{ marginTop: 4 }}>{t('settings.language')}</div>
<button className={`settings-row ${lang === 'zh' ? 'active' : ''}`} onClick={() => setLang('zh')}>
<span>{t('settings.langZh')}</span>
{lang === 'zh' && <small>✓</small>}
</button>
<button className={`settings-row ${lang === 'en' ? 'active' : ''}`} onClick={() => setLang('en')}>
<span>{t('settings.langEn')}</span>
{lang === 'en' && <small>✓</small>}
</button>
</div>
)}
<div className="workspace-toolbar">
<div className="tree-selector">
<button className={`tree-tab ${treeMenuOpen ? 'active' : ''}`} onClick={() => setTreeMenuOpen((value) => !value)}>
@@ -476,7 +513,7 @@ function Editor() {
</div>
<main className="workspace-grid">
<NodePalette customModels={treeDocument.nodeModels || []} onAddNode={addNodeAt} onAddCustomNode={addCustomNodeAt} />
<NodePalette customModels={treeDocument.nodeModels || []} onAddNode={addNodeAt} onAddCustomNode={addCustomNodeAt} onAddCustomModel={addCustomModel} />
<section className="center-stage">
<div className="canvas-region" onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}>
{viewMode === 'canvas' ? (
@@ -487,8 +524,9 @@ function Editor() {
onNodesChange={onNodesChange}
onConnect={connectNodes}
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
onNodeDoubleClick={(_event, node) => { setModalNodeId(node.id); setSelectedNodeId(node.id) }}
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }}
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false) }}
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }}
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
onNodeDragStop={() => {
if (dragStartRef.current) {
@@ -510,7 +548,7 @@ function Editor() {
defaultEdgeOptions={{ type: 'smoothstep' }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="#343a40" />
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color={theme === 'dark' ? '#343a40' : '#c4c8ce'} />
<Controls showInteractive={false} position="bottom-left" />
<MiniMap
position="bottom-right"
@@ -518,9 +556,12 @@ function Editor() {
zoomable
nodeColor={(node) => {
const category = (node.data as BehaviorNode['data']).category
return { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66' }[category]
const colors = theme === 'dark'
? { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66', root: '#eeb84a' }
: { control: '#3a72e0', decorator: '#8b5fd4', action: '#1aad6f', condition: '#d99a20', subtree: '#d9605a', root: '#d99a20' }
return colors[category] || (theme === 'dark' ? '#68717b' : '#929aa3')
}}
maskColor="rgba(10, 12, 15, 0.72)"
maskColor={theme === 'dark' ? 'rgba(10, 12, 15, 0.72)' : 'rgba(200, 205, 212, 0.45)'}
/>
</ReactFlow>
) : (
@@ -561,6 +602,19 @@ function Editor() {
<div><span>{treeGraphs.length} 棵树</span><span>{treeDocument.nodes.length} 节点</span><span>{treeDocument.edges.length} 连接</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}</span></div>
</footer>
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
{modalNodeId && (() => {
const modalNode = treeDocument.nodes.find((n) => n.id === modalNodeId)
if (!modalNode) return null
return (
<NodeDetailModal
node={modalNode}
onSave={updateNode}
onDelete={(nodeId) => { deleteNode(nodeId); setModalNodeId(null) }}
onClose={() => setModalNodeId(null)}
/>
)
})()}
</div>
)
}

View File

@@ -1,26 +1,98 @@
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { CircleDot, OctagonAlert } from 'lucide-react'
import { NODE_DEFINITION_MAP, canHaveChildren } from '../lib/catalog'
import type { BehaviorNode } from '../types'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, PortDirection } from '../types'
const MAX_INLINE_PORTS = 5
function getPortDirection(
portName: string,
portDirections: Record<string, PortDirection> | undefined,
): PortDirection | null {
if (!portDirections) return null
return portDirections[portName] || null
}
export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
const definition = NODE_DEFINITION_MAP.get(data.nodeType)
const Icon = definition?.icon || CircleDot
const status = data.runtimeStatus || 'IDLE'
const { lang } = useI18n()
// Bilingual display: show Chinese as primary when zh, English as secondary
const isZh = lang === 'zh'
const primaryLabel = isZh ? (definition?.labelZh || data.label) : data.label
const secondaryLabel = isZh ? data.label : (definition?.labelZh || '')
// Port entries for inline display
const portEntries = Object.entries(data.ports || {})
const visiblePorts = portEntries.slice(0, MAX_INLINE_PORTS)
const hiddenCount = portEntries.length - MAX_INLINE_PORTS
const isRoot = data.nodeType === 'Root'
return (
<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>
</>
)}
<div className="node-accent" />
<div className="node-top-row">
<div className="node-icon"><Icon size={17} strokeWidth={1.9} /></div>
<div className="node-copy">
<div className="node-title" title={data.label}>{data.label}</div>
<div className="node-title" title={primaryLabel}>{primaryLabel}</div>
{secondaryLabel && secondaryLabel !== primaryLabel && (
<div className="node-subtitle">{secondaryLabel}</div>
)}
{!secondaryLabel || secondaryLabel === primaryLabel ? (
<div className="node-type">{data.registrationName}</div>
) : (
<div className="node-type">{data.registrationName}</div>
)}
</div>
</div>
{/* Inline port parameters */}
{portEntries.length > 0 && (
<div className="node-ports-row">
{visiblePorts.map(([key, value]) => {
const dir = getPortDirection(key, data.portDirections)
return (
<div className="node-port-param" key={key}>
{dir && (
<span className={`port-dir-badge dir-${dir === 'input' ? 'in' : 'out'}`}>
{dir === 'input' ? 'IN' : 'OUT'}
</span>
)}
<span className="port-key" title={key}>{key}</span>
{value && <span className="port-value">: {value}</span>}
</div>
)
})}
{hiddenCount > 0 && (
<div className="node-ports-more">...+{hiddenCount} more</div>
)}
</div>
)}
{data.breakpoint && <OctagonAlert className="breakpoint-icon" size={15} aria-label="Breakpoint" />}
{status !== 'IDLE' && <span className={`runtime-badge runtime-${status.toLowerCase()}`}>{status}</span>}
{/* 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 */}
{canHaveChildren(data.nodeType) && (
<>
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
<span className="handle-label handle-label-out">OUT</span>
</>
)}
</div>
)

View File

@@ -1,6 +1,6 @@
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
import { CATEGORY_LABELS } from '../lib/catalog'
import type { BehaviorNode, BehaviorTreeDocument, ValidationIssue } from '../types'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
interface InspectorProps {
document: BehaviorTreeDocument
@@ -13,6 +13,8 @@ interface InspectorProps {
}
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) {
const { t } = useI18n()
const updatePort = (oldKey: string, key: string, value: string) => {
if (!selectedNode) return
const ports = { ...selectedNode.data.ports }
@@ -21,11 +23,27 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
onUpdateNode(selectedNode.id, { ports })
}
const updatePortDirection = (key: string, direction: PortDirection | '') => {
if (!selectedNode) return
const portDirections = { ...(selectedNode.data.portDirections || {}) }
if (direction) {
portDirections[key] = direction
} else {
delete portDirections[key]
}
onUpdateNode(selectedNode.id, { portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined })
}
const removePort = (key: string) => {
if (!selectedNode) return
const ports = { ...selectedNode.data.ports }
delete ports[key]
onUpdateNode(selectedNode.id, { ports })
const portDirections = selectedNode.data.portDirections ? { ...selectedNode.data.portDirections } : {}
delete portDirections[key]
onUpdateNode(selectedNode.id, {
ports,
portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined,
})
}
const addPort = () => {
@@ -36,88 +54,106 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
onUpdateNode(selectedNode.id, { ports: { ...selectedNode.data.ports, [key]: '' } })
}
const getPortDir = (key: string): PortDirection | '' => {
if (!selectedNode?.data.portDirections) return ''
return selectedNode.data.portDirections[key] || ''
}
return (
<aside className="right-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">INSPECTOR</span>
<h2>{selectedNode ? '节点属性' : '行为树'}</h2>
<span className="eyebrow">{t('inspector.title')}</span>
<h2>{selectedNode ? t('inspector.nodeProps') : t('inspector.treeProps')}</h2>
</div>
{selectedNode && <span className={`category-chip category-${selectedNode.data.category}`}>{CATEGORY_LABELS[selectedNode.data.category]}</span>}
{selectedNode && (
<span className={`category-chip category-${selectedNode.data.category}`}>
{t(`cat.${selectedNode.data.category}`)}
</span>
)}
</div>
<div className="inspector-scroll">
{selectedNode ? (
<>
<div className="field-group">
<label htmlFor="node-label">显示名称</label>
<label htmlFor="node-label">{t('inspector.displayName')}</label>
<input id="node-label" value={selectedNode.data.label} onChange={(event) => onUpdateNode(selectedNode.id, { label: event.target.value })} />
</div>
<div className="field-group">
<label htmlFor="registration-name">注册名称 / ID</label>
<label htmlFor="registration-name">{t('inspector.registrationName')}</label>
<input id="registration-name" value={selectedNode.data.registrationName} onChange={(event) => onUpdateNode(selectedNode.id, { registrationName: event.target.value })} />
</div>
<div className="field-row">
<div className="field-group">
<label>节点类型</label>
<label>{t('inspector.nodeType')}</label>
<div className="read-only-value">{selectedNode.data.nodeType}</div>
</div>
<div className="field-group">
<label>运行匹配 ID</label>
<label>{t('inspector.nodeId')}</label>
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
</div>
</div>
<div className="section-divider" />
<div className="section-title-row">
<div className="section-title"><Braces size={15} />端口与黑板</div>
<button className="icon-button compact" onClick={addPort} title="添加端口"><Plus size={15} /></button>
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
<button className="icon-button compact" onClick={addPort} title={t('inspector.addPort')}><Plus size={15} /></button>
</div>
<div className="ports-list">
{Object.entries(selectedNode.data.ports).map(([key, value]) => (
<div className="port-row" key={key}>
<input className="port-key" value={key} onChange={(event) => updatePort(key, event.target.value, value)} aria-label="端口名" />
<input value={value} onChange={(event) => updatePort(key, key, event.target.value)} placeholder="值或 {blackboard}" aria-label={`${key} 的值`} />
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title="删除端口"><X size={14} /></button>
<input className="port-key" value={key} onChange={(event) => updatePort(key, event.target.value, value)} aria-label={t('inspector.portKey')} />
<select
value={getPortDir(key)}
onChange={(event) => updatePortDirection(key, event.target.value as PortDirection | '')}
style={{ gridColumn: '1', gridRow: '2', height: 22, fontSize: 9, border: '1px solid var(--border-input)', borderRadius: 4, color: 'var(--text-primary)', background: 'var(--bg-input)' }}
>
<option value="">--</option>
<option value="input">IN</option>
<option value="output">OUT</option>
</select>
<input value={value} onChange={(event) => updatePort(key, key, event.target.value)} placeholder={t('inspector.portValue')} aria-label={`${key} ${t('inspector.portValue')}`} />
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title={t('inspector.deleteNode')}><X size={14} /></button>
</div>
))}
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">暂无端口</div>}
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">{t('inspector.noPorts')}</div>}
</div>
<div className="section-divider" />
<label className="toggle-row">
<span><span className="toggle-title">断点</span><span className="toggle-description">运行到此节点时标记</span></span>
<span><span className="toggle-title">{t('inspector.breakpoint')}</span><span className="toggle-description">{t('inspector.breakpointDesc')}</span></span>
<input type="checkbox" checked={selectedNode.data.breakpoint} onChange={(event) => onUpdateNode(selectedNode.id, { breakpoint: event.target.checked })} />
</label>
<div className="field-group notes-field">
<label htmlFor="node-notes">备注</label>
<label htmlFor="node-notes">{t('inspector.notes')}</label>
<textarea id="node-notes" rows={4} value={selectedNode.data.notes} onChange={(event) => onUpdateNode(selectedNode.id, { notes: event.target.value })} />
</div>
<button className="danger-button" onClick={() => onDeleteNode(selectedNode.id)}><Trash2 size={15} />删除节点</button>
<button className="danger-button" onClick={() => onDeleteNode(selectedNode.id)}><Trash2 size={15} />{t('inspector.deleteNode')}</button>
</>
) : (
<>
<div className="field-group">
<label htmlFor="tree-title">工程名称</label>
<label htmlFor="tree-title">{t('inspector.projectName')}</label>
<input id="tree-title" value={document.title} onChange={(event) => onUpdateDocument({ title: event.target.value })} />
</div>
<div className="field-group">
<label htmlFor="tree-id">主树 ID</label>
<label htmlFor="tree-id">{t('inspector.mainTreeId')}</label>
<input id="tree-id" value={document.mainTreeId} onChange={(event) => onUpdateDocument({ mainTreeId: event.target.value })} />
</div>
<div className="tree-summary">
<div><strong>{document.nodes.length}</strong><span>节点</span></div>
<div><strong>{document.edges.length}</strong><span>连接</span></div>
<div><strong>{issues.filter((item) => item.severity === 'error').length}</strong><span>错误</span></div>
<div><strong>{document.nodes.length}</strong><span>{t('inspector.nodeCount')}</span></div>
<div><strong>{document.edges.length}</strong><span>{t('inspector.edgeCount')}</span></div>
<div><strong>{issues.filter((item) => item.severity === 'error').length}</strong><span>{t('inspector.errorCount')}</span></div>
</div>
</>
)}
<div className="section-divider" />
<div className="section-title-row">
<div className="section-title"><AlertCircle size={15} />结构检查</div>
<div className="section-title"><AlertCircle size={15} />{t('inspector.structureCheck')}</div>
<span className="count-label">{issues.length}</span>
</div>
<div className="issue-list">
{!issues.length && <div className="validation-ok">结构有效</div>}
{!issues.length && <div className="validation-ok">{t('validation.valid')}</div>}
{issues.map((issue, index) => (
<button key={`${issue.message}-${index}`} className={`issue-row ${issue.severity}`} onClick={() => issue.nodeId && onSelectIssue(issue.nodeId)} disabled={!issue.nodeId}>
<span className="issue-dot" />

View File

@@ -0,0 +1,202 @@
import { Braces, Plus, Trash2, X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { NODE_DEFINITION_MAP } from '../lib/catalog'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, PortDirection } from '../types'
interface NodeDetailModalProps {
node: BehaviorNode
onSave: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
onDelete: (nodeId: string) => void
onClose: () => void
}
export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) {
const { t } = useI18n()
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
// Local working copy
const [label, setLabel] = useState(node.data.label)
const [registrationName, setRegistrationName] = useState(node.data.registrationName)
const [ports, setPorts] = useState<Record<string, string>>({ ...node.data.ports })
const [portDirections, setPortDirections] = useState<Record<string, PortDirection>>(
node.data.portDirections ? { ...node.data.portDirections } : {},
)
const [breakpoint, setBreakpoint] = useState(node.data.breakpoint)
const [notes, setNotes] = useState(node.data.notes)
const handleSave = useCallback(() => {
onSave(node.id, {
label,
registrationName,
ports,
portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined,
breakpoint,
notes,
})
onClose()
}, [node.id, label, registrationName, ports, portDirections, breakpoint, notes, onSave, onClose])
const addPort = useCallback(() => {
let key = 'port'
let idx = 1
while (key in ports) key = `port_${idx++}`
setPorts((prev) => ({ ...prev, [key]: '' }))
}, [ports])
const updatePortKey = useCallback((oldKey: string, newKey: string) => {
setPorts((prev) => {
const next = { ...prev }
const value = next[oldKey] || ''
delete next[oldKey]
next[newKey] = value
return next
})
setPortDirections((prev) => {
const next = { ...prev }
if (oldKey in next) {
next[newKey] = next[oldKey]
delete next[oldKey]
}
return next
})
}, [])
const updatePortValue = useCallback((key: string, value: string) => {
setPorts((prev) => ({ ...prev, [key]: value }))
}, [])
const updatePortDirection = useCallback((key: string, direction: PortDirection) => {
setPortDirections((prev) => ({ ...prev, [key]: direction }))
}, [])
const removePort = useCallback((key: string) => {
setPorts((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setPortDirections((prev) => {
const next = { ...prev }
delete next[key]
return next
})
}, [])
// ESC to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
const portEntries = Object.entries(ports)
return (
<div className="modal-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal-card">
<div className="modal-header">
<h2>
<span style={{ color: 'var(--category-color)' }}>
{definition?.labelZh && definition.labelZh !== definition.label ? definition.labelZh : definition?.label}
</span>
{t('modal.editNode')}
</h2>
<button className="bare-icon" onClick={onClose}><X size={18} /></button>
</div>
<div className="modal-body">
{/* Basic Info */}
<div className="field-group">
<label>{t('inspector.displayName')}</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="field-group">
<label>{t('inspector.registrationName')}</label>
<input value={registrationName} onChange={(e) => setRegistrationName(e.target.value)} />
</div>
<div className="field-row">
<div className="field-group">
<label>{t('inspector.nodeType')}</label>
<div className="read-only-value">{node.data.nodeType}</div>
</div>
<div className="field-group">
<label>{t('inspector.nodeId')}</label>
<div className="read-only-value mono" title={node.id}>{node.id.slice(0, 14)}</div>
</div>
</div>
<div className="section-divider" />
{/* Ports */}
<div className="section-title-row">
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
<button className="icon-button compact" onClick={addPort} title={t('inspector.addPort')}><Plus size={15} /></button>
</div>
<div className="ports-list">
{portEntries.map(([key, value]) => (
<div className="port-row" key={key}>
<input
className="port-key"
value={key}
onChange={(e) => updatePortKey(key, e.target.value)}
aria-label={t('inspector.portKey')}
/>
<select
value={portDirections[key] || ''}
onChange={(e) => updatePortDirection(key, e.target.value as PortDirection || 'input')}
aria-label={t('inspector.portDirection')}
>
<option value="">--</option>
<option value="input">IN</option>
<option value="output">OUT</option>
</select>
<input
value={value}
onChange={(e) => updatePortValue(key, e.target.value)}
placeholder={t('inspector.portValue')}
aria-label={`${key} ${t('inspector.portValue')}`}
/>
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title={t('inspector.deleteNode')}>
<X size={14} />
</button>
</div>
))}
{!portEntries.length && <div className="inline-empty">{t('inspector.noPorts')}</div>}
</div>
<div className="section-divider" />
{/* Breakpoint */}
<label className="toggle-row">
<span>
<span className="toggle-title">{t('inspector.breakpoint')}</span>
<span className="toggle-description">{t('inspector.breakpointDesc')}</span>
</span>
<input type="checkbox" checked={breakpoint} onChange={(e) => setBreakpoint(e.target.checked)} />
</label>
{/* Notes */}
<div className="field-group notes-field">
<label>{t('inspector.notes')}</label>
<textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
<div className="modal-footer">
<div className="footer-left">
<button className="danger-button" onClick={() => { onDelete(node.id); onClose() }}>
<Trash2 size={15} />{t('modal.delete')}
</button>
</div>
<div className="footer-right">
<button className="secondary-button" onClick={onClose}>{t('modal.cancel')}</button>
<button className="primary-button" onClick={handleSave}>{t('modal.save')}</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,24 +1,41 @@
import { Search, SquareFunction, X, Zap } from 'lucide-react'
import { Plus, Search, SquareFunction, TreePine, X, Zap } from 'lucide-react'
import { useMemo, useState } from 'react'
import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog'
import type { CustomNodeModel, NodeCategory, NodeType } from '../types'
import { NODE_DEFINITIONS, NODE_DEFINITION_MAP } from '../lib/catalog'
import { useI18n } from '../context/I18nContext'
import type { CustomNodeModel, NodeType } from '../types'
interface NodePaletteProps {
customModels: CustomNodeModel[]
onAddNode: (type: NodeType) => void
onAddCustomNode: (modelId: string) => void
onAddCustomModel: (model: CustomNodeModel) => void
}
const CATEGORY_ORDER: NodeCategory[] = ['control', 'decorator', 'action', 'condition', 'subtree']
const CATEGORY_ORDER = ['root', 'control', 'decorator', 'action', 'condition', 'subtree'] as const
export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePaletteProps) {
export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCustomModel }: NodePaletteProps) {
const { t, lang } = useI18n()
const isZh = lang === 'zh'
const [query, setQuery] = useState('')
const normalizedQuery = query.trim().toLowerCase()
const groups = useMemo(() => CATEGORY_ORDER.map((category) => ({
category,
items: NODE_DEFINITIONS.filter((item) => item.category === category)
.filter((item) => !normalizedQuery || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalizedQuery)),
})).filter((group) => group.items.length), [normalizedQuery])
const [creatingModel, setCreatingModel] = useState(false)
const [newModelName, setNewModelName] = useState('')
const [newModelType, setNewModelType] = useState<NodeType>('Action')
const groupedDefinitions = useMemo(() => {
const defs = NODE_DEFINITIONS.filter((d) => {
if (!normalizedQuery) return true
const label = isZh ? d.labelZh : d.label
const desc = isZh ? d.descriptionZh : d.description
return `${label} ${desc} ${d.type}`.toLowerCase().includes(normalizedQuery)
})
const groups = new Map<string, typeof defs>()
CATEGORY_ORDER.forEach((cat) => {
const items = defs.filter((d) => d.category === cat)
if (items.length) groups.set(cat, items)
})
return groups
}, [normalizedQuery, isZh])
const visibleModels = useMemo(() => customModels.filter((model) => {
if (!normalizedQuery) return true
@@ -35,24 +52,61 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
event.dataTransfer.effectAllowed = 'copy'
}
const submitNewModel = () => {
const trimmed = newModelName.trim()
if (!trimmed) return
const def = NODE_DEFINITION_MAP.get(newModelType)!
const model: CustomNodeModel = {
id: trimmed,
nodeType: newModelType,
category: def.category,
ports: [],
}
if (def.defaultPorts) {
model.ports = Object.entries(def.defaultPorts).map(([name, defaultValue]) => ({
name,
direction: 'input_port' as const,
type: '',
defaultValue,
description: '',
}))
}
onAddCustomModel(model)
setNewModelName('')
setNewModelType('Action')
setCreatingModel(false)
}
const catLabel = (cat: string) => {
return t(`cat.${cat}`)
}
const nodeLabel = (d: typeof NODE_DEFINITIONS[number]) => {
return isZh ? d.labelZh : d.label
}
const nodeDesc = (d: typeof NODE_DEFINITIONS[number]) => {
return isZh ? d.descriptionZh : d.description
}
return (
<aside className="left-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">LIBRARY</span>
<h2>节点</h2>
<h2>{t('palette.title')}</h2>
</div>
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
</div>
<div className="palette-search">
<Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点" aria-label="搜索节点" />
<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>}
</div>
<div className="palette-scroll">
{visibleModels.length > 0 && (
<section className="palette-group custom-model-group">
<h3>XML 自定义节点</h3>
<h3>{t('palette.customNodes')}</h3>
<div className="palette-items">
{visibleModels.map((model) => {
const Icon = model.category === 'condition' ? SquareFunction : Zap
@@ -63,7 +117,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
key={model.id}
onDragStart={(event) => beginCustomDrag(event, model.id)}
onClick={() => onAddCustomNode(model.id)}
title={`${CATEGORY_LABELS[model.category]} · ${model.ports.length} 个端口`}
title={`${catLabel(model.category)} · ${model.ports.length} 个端口`}
>
<span className="palette-item-icon"><Icon size={16} /></span>
<span className="palette-item-name">{model.id}</span>
@@ -73,12 +127,42 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
</div>
</section>
)}
{groups.map(({ category, items }) => (
{creatingModel ? (
<div className="palette-custom-form">
<div className="palette-custom-form-row">
<label>{t('palette.customModelName')}</label>
<input
value={newModelName}
onChange={(e) => setNewModelName(e.target.value)}
placeholder="MyNode"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') submitNewModel(); if (e.key === 'Escape') setCreatingModel(false); }}
/>
</div>
<div className="palette-custom-form-row">
<label>{t('palette.customModelType')}</label>
<select value={newModelType} onChange={(e) => setNewModelType(e.target.value as NodeType)}>
{NODE_DEFINITIONS.filter((d) => d.type !== 'Root').map((d) => (
<option key={d.type} value={d.type}>{isZh ? d.labelZh : d.label} ({d.type})</option>
))}
</select>
</div>
<div className="palette-custom-form-actions">
<button className="primary-button compact" onClick={submitNewModel}>{t('palette.customModelCreate')}</button>
<button className="secondary-button compact" onClick={() => setCreatingModel(false)}>{t('modal.cancel')}</button>
</div>
</div>
) : (
<button className="palette-add-custom" onClick={() => setCreatingModel(true)}>
<Plus size={14} /> {t('palette.customModelAdd')}
</button>
)}
{Array.from(groupedDefinitions.entries()).map(([category, items]) => (
<section className="palette-group" key={category}>
<h3>{CATEGORY_LABELS[category]}</h3>
<h3>{catLabel(category)}</h3>
<div className="palette-items">
{items.map((item) => {
const Icon = item.icon
const Icon = item.icon || TreePine
return (
<button
className={`palette-item category-${category}`}
@@ -86,17 +170,17 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
key={item.type}
onDragStart={(event) => beginDrag(event, item.type)}
onClick={() => onAddNode(item.type)}
title={item.description}
title={nodeDesc(item)}
>
<span className="palette-item-icon"><Icon size={16} /></span>
<span className="palette-item-name">{item.label}</span>
<span className="palette-item-name">{nodeLabel(item)}</span>
</button>
)
})}
</div>
</section>
))}
{!groups.length && !visibleModels.length && <div className="panel-empty">没有匹配节点</div>}
{!groupedDefinitions.size && !visibleModels.length && <div className="panel-empty">{t('palette.noMatch')}</div>}
</div>
</aside>
)

View File

@@ -0,0 +1,52 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import en from '../i18n/en'
import zh from '../i18n/zh'
export type Language = 'en' | 'zh'
const DICTS: Record<Language, Record<string, string>> = { en, zh }
interface I18nContextValue {
lang: Language
setLang: (lang: Language) => void
t: (key: string, fallback?: string) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
function readLanguage(): Language {
try {
const stored = localStorage.getItem('arborflow-lang')
if (stored === 'zh' || stored === 'en') return stored
} catch { /* localStorage unavailable */ }
return 'en'
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Language>(readLanguage)
const setLang = useCallback((next: Language) => {
setLangState(next)
try { localStorage.setItem('arborflow-lang', next) } catch { /* noop */ }
}, [])
useEffect(() => {
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
}, [lang])
const t = useCallback((key: string, fallback?: string): string => {
return DICTS[lang]?.[key] || fallback || key
}, [lang])
return (
<I18nContext.Provider value={{ lang, setLang, t }}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}

View File

@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function readTheme(): Theme {
try {
const stored = localStorage.getItem('arborflow-theme')
if (stored === 'light' || stored === 'dark') return stored
} catch { /* localStorage unavailable */ }
return 'dark'
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(readTheme)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
try { localStorage.setItem('arborflow-theme', theme) } catch { /* noop */ }
}, [theme])
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

180
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,180 @@
const en: Record<string, string> = {
/* ── App Chrome ── */
'app.subtitle': 'Behavior Tree Studio',
/* ── Toolbar ── */
'toolbar.canvas': 'Canvas',
'toolbar.xml': 'XML',
'toolbar.autoLayout': 'Auto Layout',
'toolbar.monitor': 'Monitor',
/* ── Header Actions ── */
'action.newProject': 'New Project',
'action.openProject': 'Open Project',
'action.saveProject': 'Save Project',
'action.undo': 'Undo',
'action.redo': 'Redo',
'action.importXml': 'Import XML',
'action.exportXml': 'Export XML',
'action.settings': 'Settings',
/* ── Settings ── */
'settings.title': 'Settings',
'settings.theme': 'Theme',
'settings.language': 'Language',
'settings.dark': 'Dark',
'settings.light': 'Light',
'settings.langZh': '中文',
'settings.langEn': 'English',
/* ── Tree Menu ── */
'tree.heading': 'Behavior Trees',
'tree.main': 'MAIN',
/* ── Node Palette ── */
'palette.title': 'Nodes',
'palette.searchPlaceholder': 'Search nodes',
'palette.customNodes': 'XML Custom Nodes',
'palette.noMatch': 'No matching nodes',
'palette.customModelAdd': 'Add Custom Node',
'palette.customModelName': 'Node Name / ID',
'palette.customModelType': 'Base Type',
'palette.customModelCreate': 'Create',
/* ── Node Category Labels ── */
'cat.control': 'Control',
'cat.decorator': 'Decorator',
'cat.action': 'Action',
'cat.condition': 'Condition',
'cat.subtree': 'SubTree',
'cat.root': 'Root',
/* ── Node Type Names ── */
'node.Sequence': 'Sequence',
'node.Fallback': 'Fallback',
'node.Parallel': 'Parallel',
'node.ReactiveSequence': 'Reactive Sequence',
'node.ReactiveFallback': 'Reactive Fallback',
'node.Inverter': 'Inverter',
'node.RetryUntilSuccessful': 'Retry',
'node.Repeat': 'Repeat',
'node.ForceSuccess': 'Force Success',
'node.ForceFailure': 'Force Failure',
'node.Action': 'Action',
'node.Condition': 'Condition',
'node.SubTree': 'SubTree',
'node.AlwaysSuccess': 'Always Success',
'node.AlwaysFailure': 'Always Failure',
'node.Root': 'Root',
/* ── Node Descriptions ── */
'desc.Sequence': 'Execute children in order, stop on failure',
'desc.Fallback': 'Try children in order, stop on success',
'desc.Parallel': 'Execute multiple children in parallel',
'desc.ReactiveSequence': 'Re-evaluate from first child each tick',
'desc.ReactiveFallback': 'Reactive fallback control node',
'desc.Inverter': 'Invert success/failure of child',
'desc.RetryUntilSuccessful': 'Retry up to N times on failure',
'desc.Repeat': 'Repeat execution up to N cycles',
'desc.ForceSuccess': 'Force return success',
'desc.ForceFailure': 'Force return failure',
'desc.Action': 'Invoke a ROS action or business logic',
'desc.Condition': 'Check blackboard or environment condition',
'desc.SubTree': 'Call another behavior tree',
'desc.AlwaysSuccess': 'Always return success',
'desc.AlwaysFailure': 'Always return failure',
'desc.Root': 'Root node of the behavior tree',
/* ── Inspector ── */
'inspector.title': 'INSPECTOR',
'inspector.treeProps': 'Behavior Tree',
'inspector.nodeProps': 'Node Properties',
'inspector.displayName': 'Display Name',
'inspector.registrationName': 'Registration Name / ID',
'inspector.nodeType': 'Node Type',
'inspector.nodeId': 'Runtime Match ID',
'inspector.ports': 'Ports & Blackboard',
'inspector.addPort': 'Add Port',
'inspector.noPorts': 'No ports',
'inspector.portKey': 'Port name',
'inspector.portValue': 'Value or {blackboard}',
'inspector.portDirection': 'Direction',
'inspector.breakpoint': 'Breakpoint',
'inspector.breakpointDesc': 'Flag when execution reaches this node',
'inspector.notes': 'Notes',
'inspector.deleteNode': 'Delete Node',
'inspector.structureCheck': 'Structure Check',
'inspector.projectName': 'Project Name',
'inspector.mainTreeId': 'Main Tree ID',
'inspector.nodeCount': 'nodes',
'inspector.edgeCount': 'edges',
'inspector.errorCount': 'errors',
/* ── Modal ── */
'modal.editNode': 'Edit Node',
'modal.save': 'Save',
'modal.cancel': 'Cancel',
'modal.delete': 'Delete',
/* ── Validation ── */
'validation.valid': 'Structure valid',
'validation.emptyCanvas': 'Canvas is empty, add a root node',
'validation.missingRoot': 'No root node — tree may contain cycles',
'validation.tooManyRoots': 'root nodes detected — connect into a single tree before exporting XML',
'validation.cannotAddChild': 'Cannot add more children',
'validation.singleParent': 'A node can only have one parent',
'validation.wouldCycle': 'This connection would create a cycle',
'validation.xmlEmpty': 'Canvas is empty, cannot export XML',
'validation.xmlMultiRoot': 'Must have exactly one root node',
'validation.xmlParseError': 'XML parse error',
'validation.xmlNoTree': 'No <BehaviorTree> element found',
'validation.tooManyChildren': 'allowed children',
/* ── Toast ── */
'toast.projectOpened': 'Project opened',
'toast.projectSaved': 'Project saved',
'toast.projectDownloaded': 'Project downloaded',
'toast.xmlImported': 'XML imported',
'toast.xmlExported': 'BehaviorTree.CPP XML exported',
'toast.xmlCopied': 'XML copied',
'toast.saveFailed': 'Save failed',
'toast.openFailed': 'Open project failed',
'toast.importFailed': 'XML import failed',
'toast.exportFailed': 'XML export failed',
'toast.xmlGenFailed': 'Cannot generate XML',
/* ── Confirm ── */
'confirm.unsavedNew': 'Unsaved changes. Create new project anyway?',
/* ── Status Bar ── */
'status.trees': 'trees',
'status.nodes': 'nodes',
'status.edges': 'edges',
'status.valid': 'Structure valid',
'status.errors': 'structure errors',
'status.unsaved': 'Unsaved',
'status.webPreview': 'Web Preview',
'status.desktop': 'Desktop',
/* ── XML View ── */
'xml.copyButton': 'Copy XML',
/* ── Monitor ── */
'monitor.disconnected': 'Disconnected',
'monitor.connecting': 'Connecting',
'monitor.connected': 'Connected',
'monitor.error': 'Connection Error',
'monitor.eventStream': 'Event Stream',
'monitor.waiting': 'Waiting for node status',
'monitor.unmatched': 'Unmatched',
'monitor.endpoint': 'ROS WebSocket Address',
'monitor.topic': 'rosbridge Topic',
'monitor.autoReconnect': 'Auto Reconnect',
'monitor.disconnect': 'Disconnect',
'monitor.connect': 'Connect',
'monitor.messages': 'Messages',
'monitor.latency': 'Latency',
'monitor.matched': 'Matched',
}
export default en

180
src/i18n/zh.ts Normal file
View File

@@ -0,0 +1,180 @@
const zh: Record<string, string> = {
/* ── App Chrome ── */
'app.subtitle': '行为树编辑器',
/* ── Toolbar ── */
'toolbar.canvas': '画布',
'toolbar.xml': 'XML',
'toolbar.autoLayout': '自动布局',
'toolbar.monitor': 'Monitor',
/* ── Header Actions ── */
'action.newProject': '新建工程',
'action.openProject': '打开工程',
'action.saveProject': '保存工程',
'action.undo': '撤销',
'action.redo': '重做',
'action.importXml': '导入 XML',
'action.exportXml': '导出 XML',
'action.settings': '设置',
/* ── Settings ── */
'settings.title': '设置',
'settings.theme': '主题',
'settings.language': '语言',
'settings.dark': '深色模式',
'settings.light': '浅色模式',
'settings.langZh': '中文',
'settings.langEn': 'English',
/* ── Tree Menu ── */
'tree.heading': '行为树',
'tree.main': 'MAIN',
/* ── Node Palette ── */
'palette.title': '节点',
'palette.searchPlaceholder': '搜索节点',
'palette.customNodes': 'XML 自定义节点',
'palette.noMatch': '没有匹配节点',
'palette.customModelAdd': '添加自定义节点',
'palette.customModelName': '节点名称 / ID',
'palette.customModelType': '基础类型',
'palette.customModelCreate': '创建',
/* ── Node Category Labels ── */
'cat.control': '控制',
'cat.decorator': '装饰',
'cat.action': '动作',
'cat.condition': '条件',
'cat.subtree': '子树',
'cat.root': '根',
/* ── Node Type Names — Bilingual Display ── */
'node.Sequence': '顺序执行',
'node.Fallback': '选择回退',
'node.Parallel': '并行执行',
'node.ReactiveSequence': '响应序列',
'node.ReactiveFallback': '响应回退',
'node.Inverter': '反转',
'node.RetryUntilSuccessful': '重试',
'node.Repeat': '重复',
'node.ForceSuccess': '强制成功',
'node.ForceFailure': '强制失败',
'node.Action': '动作',
'node.Condition': '条件',
'node.SubTree': '子树',
'node.AlwaysSuccess': '始终成功',
'node.AlwaysFailure': '始终失败',
'node.Root': '根节点',
/* ── Node Descriptions ── */
'desc.Sequence': '依次执行子节点,遇失败停止',
'desc.Fallback': '依次尝试子节点,遇成功停止',
'desc.Parallel': '并行执行多个子节点',
'desc.ReactiveSequence': '每次 tick 从头重新评估',
'desc.ReactiveFallback': '响应式回退控制节点',
'desc.Inverter': '反转子节点的成功/失败结果',
'desc.RetryUntilSuccessful': '失败后重试指定次数',
'desc.Repeat': '重复执行指定次数',
'desc.ForceSuccess': '强制返回成功状态',
'desc.ForceFailure': '强制返回失败状态',
'desc.Action': '调用 ROS 动作或业务逻辑',
'desc.Condition': '检查黑板变量或环境条件',
'desc.SubTree': '调用另一棵行为树',
'desc.AlwaysSuccess': '始终返回成功',
'desc.AlwaysFailure': '始终返回失败',
'desc.Root': '行为树的根节点',
/* ── Inspector ── */
'inspector.title': 'INSPECTOR',
'inspector.treeProps': '行为树',
'inspector.nodeProps': '节点属性',
'inspector.displayName': '显示名称',
'inspector.registrationName': '注册名称 / ID',
'inspector.nodeType': '节点类型',
'inspector.nodeId': '运行匹配 ID',
'inspector.ports': '端口与黑板',
'inspector.addPort': '添加端口',
'inspector.noPorts': '暂无端口',
'inspector.portKey': '端口名',
'inspector.portValue': '值或 {blackboard}',
'inspector.portDirection': '方向',
'inspector.breakpoint': '断点',
'inspector.breakpointDesc': '运行到此节点时标记',
'inspector.notes': '备注',
'inspector.deleteNode': '删除节点',
'inspector.structureCheck': '结构检查',
'inspector.projectName': '工程名称',
'inspector.mainTreeId': '主树 ID',
'inspector.nodeCount': '节点',
'inspector.edgeCount': '连接',
'inspector.errorCount': '错误',
/* ── Modal ── */
'modal.editNode': '编辑节点',
'modal.save': '保存',
'modal.cancel': '取消',
'modal.delete': '删除',
/* ── Validation ── */
'validation.valid': '结构有效',
'validation.emptyCanvas': '画布为空,请添加根节点',
'validation.missingRoot': '没有根节点,树中可能存在环路',
'validation.tooManyRoots': '个根节点,导出 XML 前必须连接为一棵树',
'validation.cannotAddChild': '不能再添加子节点',
'validation.singleParent': '一个节点只能有一个父节点',
'validation.wouldCycle': '此连接会形成环路',
'validation.xmlEmpty': '画布为空,无法导出 XML',
'validation.xmlMultiRoot': '必须只有一个根节点',
'validation.xmlParseError': 'XML 解析失败',
'validation.xmlNoTree': '未找到 <BehaviorTree> 元素',
'validation.tooManyChildren': '个允许的子节点',
/* ── Toast ── */
'toast.projectOpened': '工程已打开',
'toast.projectSaved': '工程已保存',
'toast.projectDownloaded': '工程已下载',
'toast.xmlImported': 'XML 已导入',
'toast.xmlExported': 'BehaviorTree.CPP XML 已导出',
'toast.xmlCopied': 'XML 已复制',
'toast.saveFailed': '保存失败',
'toast.openFailed': '打开工程失败',
'toast.importFailed': '导入 XML 失败',
'toast.exportFailed': '导出 XML 失败',
'toast.xmlGenFailed': '无法生成 XML',
/* ── Confirm ── */
'confirm.unsavedNew': '当前工程有未保存更改,仍要新建吗?',
/* ── Status Bar ── */
'status.trees': '棵树',
'status.nodes': '节点',
'status.edges': '连接',
'status.valid': '结构有效',
'status.errors': '个结构错误',
'status.unsaved': '未保存',
'status.webPreview': 'Web Preview',
'status.desktop': 'Desktop',
/* ── XML View ── */
'xml.copyButton': '复制 XML',
/* ── Monitor ── */
'monitor.disconnected': '未连接',
'monitor.connecting': '连接中',
'monitor.connected': '已连接',
'monitor.error': '连接错误',
'monitor.eventStream': '事件流',
'monitor.waiting': '等待节点状态',
'monitor.unmatched': '未匹配',
'monitor.endpoint': 'ROS WebSocket 地址',
'monitor.topic': 'rosbridge Topic',
'monitor.autoReconnect': '自动重连',
'monitor.disconnect': '断开',
'monitor.connect': '连接',
'monitor.messages': '消息',
'monitor.latency': '延迟',
'monitor.matched': '匹配',
}
export default zh

View File

@@ -16,10 +16,13 @@ import type { BehaviorNode, CustomNodeModel, NodeCategory, NodeType } from '../t
export interface NodeDefinition {
type: NodeType
label: string
labelZh: string
description: string
descriptionZh: string
category: NodeCategory
icon: LucideIcon
defaultPorts?: Record<string, string>
defaultPortDirections?: Record<string, 'input' | 'output'>
}
export const CATEGORY_LABELS: Record<NodeCategory, string> = {
@@ -28,24 +31,26 @@ export const CATEGORY_LABELS: Record<NodeCategory, string> = {
action: '动作',
condition: '条件',
subtree: '子树',
root: '根',
}
export const NODE_DEFINITIONS: NodeDefinition[] = [
{ type: 'Sequence', label: 'Sequence', description: '依次执行,遇失败停止', category: 'control', icon: ListTree },
{ type: 'Fallback', label: 'Fallback', description: '依次尝试,遇成功停止', category: 'control', icon: GitBranch },
{ type: 'Parallel', label: 'Parallel', description: '并行执行多个子节点', category: 'control', icon: Split, defaultPorts: { success_count: '1', failure_count: '1' } },
{ type: 'ReactiveSequence', label: 'Reactive Sequence', description: '每次 tick 从首节点重评估', category: 'control', icon: RotateCcw },
{ type: 'ReactiveFallback', label: 'Reactive Fallback', description: '响应式回退控制', category: 'control', icon: RotateCcw },
{ type: 'Inverter', label: 'Inverter', description: '反转成功与失败结果', category: 'decorator', icon: Repeat2 },
{ type: 'RetryUntilSuccessful', label: 'Retry', description: '失败后重试指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_attempts: '3' } },
{ type: 'Repeat', label: 'Repeat', description: '重复执行指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_cycles: '3' } },
{ type: 'ForceSuccess', label: 'Force Success', description: '强制返回成功', category: 'decorator', icon: ShieldCheck },
{ type: 'ForceFailure', label: 'Force Failure', description: '强制返回失败', category: 'decorator', icon: ShieldX },
{ type: 'Action', label: 'Action', description: '调用 ROS 动作或业务逻辑', category: 'action', icon: Zap },
{ type: 'Condition', label: 'Condition', description: '检查黑板或环境条件', category: 'condition', icon: SquareFunction },
{ type: 'SubTree', label: 'SubTree', description: '调用另一棵行为树', category: 'subtree', icon: TreePine },
{ type: 'AlwaysSuccess', label: 'Always Success', description: '始终返回成功', category: 'action', icon: ShieldCheck },
{ type: 'AlwaysFailure', label: 'Always Failure', description: '始终返回失败', category: 'action', icon: ShieldX },
{ type: 'Sequence', label: 'Sequence', labelZh: '顺序执行', description: 'Execute children in order, stop on failure', descriptionZh: '依次执行子节点,遇失败停止', category: 'control', icon: ListTree },
{ type: 'Fallback', label: 'Fallback', labelZh: '选择回退', description: 'Try children in order, stop on success', descriptionZh: '依次尝试子节点,遇成功停止', category: 'control', icon: GitBranch },
{ type: 'Parallel', label: 'Parallel', labelZh: '并行执行', description: 'Execute multiple children in parallel', descriptionZh: '并行执行多个子节点', category: 'control', icon: Split, defaultPorts: { success_count: '1', failure_count: '1' }, defaultPortDirections: { success_count: 'input', failure_count: 'input' } },
{ type: 'ReactiveSequence', label: 'Reactive Sequence', labelZh: '响应序列', description: 'Re-evaluate from first child each tick', descriptionZh: '每次 tick 从头重新评估', category: 'control', icon: RotateCcw },
{ type: 'ReactiveFallback', label: 'Reactive Fallback', labelZh: '响应回退', description: 'Reactive fallback control node', descriptionZh: '响应式回退控制节点', category: 'control', icon: RotateCcw },
{ type: 'Inverter', label: 'Inverter', labelZh: '反转', description: 'Invert success/failure of child', descriptionZh: '反转子节点的成功/失败结果', category: 'decorator', icon: Repeat2 },
{ type: 'RetryUntilSuccessful', label: 'Retry', labelZh: '重试', description: 'Retry up to N times on failure', descriptionZh: '失败后重试指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_attempts: '3' }, defaultPortDirections: { num_attempts: 'input' } },
{ type: 'Repeat', label: 'Repeat', labelZh: '重复', description: 'Repeat execution up to N cycles', descriptionZh: '重复执行指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_cycles: '3' }, defaultPortDirections: { num_cycles: 'input' } },
{ type: 'ForceSuccess', label: 'Force Success', labelZh: '强制成功', description: 'Force return success', descriptionZh: '强制返回成功状态', category: 'decorator', icon: ShieldCheck },
{ type: 'ForceFailure', label: 'Force Failure', labelZh: '强制失败', description: 'Force return failure', descriptionZh: '强制返回失败状态', category: 'decorator', icon: ShieldX },
{ type: 'Action', label: 'Action', labelZh: '动作', description: 'Invoke a ROS action or business logic', descriptionZh: '调用 ROS 动作或业务逻辑', category: 'action', icon: Zap },
{ type: 'Condition', label: 'Condition', labelZh: '条件', description: 'Check blackboard or environment condition', descriptionZh: '检查黑板变量或环境条件', category: 'condition', icon: SquareFunction },
{ type: 'SubTree', label: 'SubTree', labelZh: '子树', description: 'Call another behavior tree', descriptionZh: '调用另一棵行为树', category: 'subtree', icon: TreePine },
{ type: 'AlwaysSuccess', label: 'Always Success', labelZh: '始终成功', description: 'Always return success', descriptionZh: '始终返回成功', category: 'action', icon: ShieldCheck },
{ type: 'AlwaysFailure', label: 'Always Failure', labelZh: '始终失败', description: 'Always return failure', descriptionZh: '始终返回失败', category: 'action', icon: ShieldX },
{ type: 'Root', label: 'Root', labelZh: '根节点', description: 'Root node of the behavior tree', descriptionZh: '行为树的根节点', category: 'control', icon: TreePine },
]
export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((definition) => [definition.type, definition]))
@@ -53,7 +58,7 @@ export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((definition) =>
export function createNode(type: NodeType, position = { x: 80, y: 80 }): BehaviorNode {
const definition = NODE_DEFINITION_MAP.get(type)!
const id = `node_${crypto.randomUUID().replaceAll('-', '').slice(0, 10)}`
const customName = type === 'Action' ? 'MyAction' : type === 'Condition' ? 'MyCondition' : type === 'SubTree' ? 'SubTreeID' : type
const customName = type === 'Root' ? 'Root' : type === 'Action' ? 'MyAction' : type === 'Condition' ? 'MyCondition' : type === 'SubTree' ? 'SubTreeID' : type
return {
id,
type: 'behavior',
@@ -64,6 +69,7 @@ export function createNode(type: NodeType, position = { x: 80, y: 80 }): Behavio
category: definition.category,
registrationName: customName,
ports: { ...(definition.defaultPorts || {}) },
portDirections: definition.defaultPortDirections ? { ...definition.defaultPortDirections } : undefined,
notes: '',
breakpoint: false,
},
@@ -91,3 +97,7 @@ export function maxChildren(type: NodeType) {
if (['Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure'].includes(type)) return 1
return canHaveChildren(type) ? Number.POSITIVE_INFINITY : 0
}
export function isRoot(type: NodeType) {
return type === 'Root'
}

View File

@@ -8,31 +8,36 @@ export function createDemoDocument(): BehaviorTreeDocument {
mainTreeId: 'MainTree',
nodes: [
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 60 },
id: 'root_node', type: 'behavior', position: { x: 475, y: 20 },
data: { label: 'Root', nodeType: 'Root', category: 'root', registrationName: 'Root', ports: {}, notes: '', breakpoint: false },
},
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 140 },
data: { label: 'Mission Sequence', nodeType: 'Sequence', category: 'control', registrationName: 'Sequence', ports: {}, notes: '主任务流程', breakpoint: false },
},
{
id: 'battery_check', type: 'behavior', position: { x: 170, y: 240 },
data: { label: 'Battery OK?', nodeType: 'Condition', category: 'condition', registrationName: 'BatteryOK', ports: { min_level: '{battery_threshold}' }, notes: '', breakpoint: false },
id: 'battery_check', type: 'behavior', position: { x: 170, y: 320 },
data: { label: 'Battery OK?', nodeType: 'Condition', category: 'condition', registrationName: 'BatteryOK', ports: { min_level: '{battery_threshold}' }, portDirections: { min_level: 'input' }, notes: '', breakpoint: false },
},
{
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 240 },
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 320 },
data: { label: 'Navigate Safely', nodeType: 'Fallback', category: 'control', registrationName: 'Fallback', ports: {}, notes: '', breakpoint: false },
},
{
id: 'report_action', type: 'behavior', position: { x: 785, y: 240 },
data: { label: 'Report Result', nodeType: 'Action', category: 'action', registrationName: 'ReportResult', ports: { topic: '/mission/result' }, notes: '', breakpoint: false },
id: 'report_action', type: 'behavior', position: { x: 785, y: 320 },
data: { label: 'Report Result', nodeType: 'Action', category: 'action', registrationName: 'ReportResult', ports: { topic: '/mission/result' }, portDirections: { topic: 'input' }, notes: '', breakpoint: false },
},
{
id: 'at_goal', type: 'behavior', position: { x: 340, y: 430 },
data: { label: 'Already At Goal?', nodeType: 'Condition', category: 'condition', registrationName: 'AtGoal', ports: { target: '{goal}' }, notes: '', breakpoint: false },
id: 'at_goal', type: 'behavior', position: { x: 340, y: 520 },
data: { label: 'Already At Goal?', nodeType: 'Condition', category: 'condition', registrationName: 'AtGoal', ports: { target: '{goal}' }, portDirections: { target: 'input' }, notes: '', breakpoint: false },
},
{
id: 'navigate_action', type: 'behavior', position: { x: 610, y: 430 },
data: { label: 'Navigate To Pose', nodeType: 'Action', category: 'action', registrationName: 'NavigateToPose', ports: { goal: '{goal}', server_timeout: '5000' }, notes: '调用 Nav2 action server', breakpoint: true },
id: 'navigate_action', type: 'behavior', position: { x: 610, y: 520 },
data: { label: 'Navigate To Pose', nodeType: 'Action', category: 'action', registrationName: 'NavigateToPose', ports: { goal: '{goal}', server_timeout: '5000' }, portDirections: { goal: 'input', server_timeout: 'input' }, notes: '调用 Nav2 action server', breakpoint: true },
},
],
edges: [
{ id: 'e-root-seq', source: 'root_node', target: 'root_sequence', type: 'smoothstep' },
{ id: 'e-root-battery', source: 'root_sequence', target: 'battery_check', type: 'smoothstep' },
{ id: 'e-root-nav', source: 'root_sequence', target: 'navigation_fallback', type: 'smoothstep' },
{ id: 'e-root-report', source: 'root_sequence', target: 'report_action', type: 'smoothstep' },

View File

@@ -51,10 +51,23 @@ function renderTree(graph: BehaviorTreeGraph) {
})
children.forEach((items) => items.sort((a, b) => a.position.x - b.position.x))
// Skip Root nodes — their children become the XML root(s)
let entryRoot = roots[0]
if (entryRoot.data.nodeType === 'Root') {
const rootChildren = children.get(entryRoot.id) || []
if (rootChildren.length === 0) throw new Error(`${graph.id} 的根节点没有子节点`)
if (rootChildren.length > 1) throw new Error(`${graph.id} 的 Root 节点下有多个子节点,只能有一个`)
entryRoot = rootChildren[0]
}
const visited = new Set<string>()
const renderNode = (node: BehaviorNode, depth: number): string => {
if (visited.has(node.id)) throw new Error(`${graph.id} 中存在重复引用或环路`)
visited.add(node.id)
if (node.data.nodeType === 'Root') {
const rootChildren = children.get(node.id) || []
return rootChildren.map((child) => renderNode(child, depth)).join('\n')
}
const indent = ' '.repeat(depth)
const tag = nodeTag(node)
const attributes: Array<[string, string]> = []
@@ -64,13 +77,13 @@ function renderTree(graph: BehaviorTreeGraph) {
if (key.trim()) attributes.push([key.trim(), value])
})
const attrs = attributes.map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join('')
const childNodes = children.get(node.id) || []
const childNodes = (children.get(node.id) || []).filter((c) => c.data.nodeType !== 'Root')
if (childNodes.length === 0) return `${indent}<${tag}${attrs}/>`
const content = childNodes.map((child) => renderNode(child, depth + 1)).join('\n')
return `${indent}<${tag}${attrs}>\n${content}\n${indent}</${tag}>`
}
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(roots[0], 2)}\n </BehaviorTree>`
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(entryRoot, 2)}\n </BehaviorTree>`
}
function renderNodeModels(models: CustomNodeModel[]) {

View File

@@ -3,9 +3,15 @@ import ReactDOM from 'react-dom/client'
import '@xyflow/react/dist/style.css'
import './styles.css'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import { I18nProvider } from './context/I18nContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<I18nProvider>
<App />
</I18nProvider>
</ThemeProvider>
</React.StrictMode>,
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import type { Edge, Node } from '@xyflow/react'
export type NodeCategory = 'control' | 'decorator' | 'action' | 'condition' | 'subtree'
export type NodeCategory = 'control' | 'decorator' | 'action' | 'condition' | 'subtree' | 'root'
export type PortDirection = 'input' | 'output' | 'inout'
export type NodeType =
| 'Sequence'
@@ -18,6 +20,7 @@ export type NodeType =
| 'SubTree'
| 'AlwaysSuccess'
| 'AlwaysFailure'
| 'Root'
export type RuntimeStatus = 'IDLE' | 'RUNNING' | 'SUCCESS' | 'FAILURE' | 'SKIPPED'
@@ -27,6 +30,7 @@ export type BehaviorNodeData = {
category: NodeCategory
registrationName: string
ports: Record<string, string>
portDirections?: Record<string, PortDirection>
notes: string
breakpoint: boolean
xmlTag?: string

144
test_rmul_2025.xml Normal file
View File

@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<root BTCPP_format="4">
<BehaviorTree ID="check_game_start">
<Fallback>
<IsGameStatus name="IsGameStart"
max_remain_time="420"
min_remain_time="0"
expected_game_progress="4"
key_port="{@referee_gameStatus}"/>
<ForceFailure>
<Sequence>
<PublishSpinSpeed spin_speed="0.0"
duration="0.0"
topic_name="cmd_spin"/>
<PublishTwist v_y="0.0"
v_yaw="0.0"
v_x="0.0"
duration="0.0"
topic_name="cmd_vel"/>
</Sequence>
</ForceFailure>
</Fallback>
</BehaviorTree>
<BehaviorTree ID="check_robot_status">
<Fallback>
<IsStatusOK ammo_min="0"
heat_max="350"
hp_min="200"
key_port="{@referee_robotStatus}"/>
<ForceFailure>
<Fallback>
<IsRfidDetected friendly_supply_zone_exchange="false"
friendly_supply_zone_non_exchange="true"
center_gain_point="false"
friendly_fortress_gain_point="false"
key_port="{@referee_rfidStatus}"/>
<PubNav2Goal goal="0;0;0"
topic_name="goal_pose"/>
</Fallback>
</ForceFailure>
</Fallback>
</BehaviorTree>
<BehaviorTree ID="rmul_2025">
<KeepRunningUntilFailure>
<ForceSuccess>
<ReactiveSequence>
<SubTree ID="check_game_start"/>
<PublishSpinSpeed spin_speed="7.0"
duration="0.0"
topic_name="cmd_spin"/>
<SubTree ID="check_robot_status"/>
<PubNav2Goal goal="4.65;-3.5;0"
topic_name="goal_pose"/>
</ReactiveSequence>
</ForceSuccess>
</KeepRunningUntilFailure>
</BehaviorTree>
<!-- Description of Node Models (used by Groot) -->
<TreeNodesModel>
<Condition ID="IsGameStatus">
<input_port name="max_remain_time"
default="420"
type="int">Maximum remaining time (s)</input_port>
<input_port name="min_remain_time"
default="0"
type="int">Minimum remaining time (s)</input_port>
<input_port name="expected_game_progress"
default="4"
type="int">Expected game progress stage</input_port>
<input_port name="key_port"
default="{@referee_gameStatus}"
type="pb_rm_interfaces::msg::GameStatus_&lt;std::allocator&lt;void&gt; &gt;">GameStatus port on blackboard</input_port>
</Condition>
<Condition ID="IsRfidDetected">
<input_port name="friendly_supply_zone_exchange"
default="false"
type="bool">己方与兑换区重叠的补给区</input_port>
<input_port name="friendly_supply_zone_non_exchange"
default="false"
type="bool">己方与兑换区不重叠的补给区 / RMUL 补给区</input_port>
<input_port name="center_gain_point"
default="false"
type="bool">中心增益点(仅 RMUL 适用)</input_port>
<input_port name="friendly_fortress_gain_point"
default="false"
type="bool">己方堡垒增益点</input_port>
<input_port name="key_port"
default="{@referee_rfidStatus}"
type="pb_rm_interfaces::msg::RfidStatus_&lt;std::allocator&lt;void&gt; &gt;">RfidStatus port on blackboard</input_port>
</Condition>
<Condition ID="IsStatusOK">
<input_port name="ammo_min"
default="0"
type="int">Lower then minimum ammo will return FAILURE</input_port>
<input_port name="heat_max"
default="350"
type="int">Maximum heat. NOTE: Sentry heat limit is 400</input_port>
<input_port name="hp_min"
default="300"
type="int">Minimum HP. NOTE: Sentry init/max HP is 400</input_port>
<input_port name="key_port"
default="{@referee_robotStatus}"
type="pb_rm_interfaces::msg::RobotStatus_&lt;std::allocator&lt;void&gt; &gt;">RobotStatus port on blackboard</input_port>
</Condition>
<Condition ID="PubNav2Goal">
<input_port name="goal"
default="0;0;0"
type="geometry_msgs::msg::PoseStamped_&lt;std::allocator&lt;void&gt; &gt;">Expected goal pose that send to nav2. Fill with format `x;y;yaw`</input_port>
<input_port name="topic_name"
default="__default__placeholder__"
type="std::string">Topic name</input_port>
</Condition>
<Action ID="PublishSpinSpeed">
<input_port name="spin_speed"
default="0.000000"
type="double">Angular Z velocity (rad/s)</input_port>
<input_port name="duration"
type="std::chrono::milliseconds">Publish then sleep duration in milliseconds</input_port>
<input_port name="topic_name"
default="__default__placeholder__"
type="std::string">Topic name</input_port>
</Action>
<Action ID="PublishTwist">
<input_port name="v_y"
default="0.000000"
type="double">Linear Y velocity (m/s)</input_port>
<input_port name="v_yaw"
default="0.000000"
type="double">Angular Z velocity (rad/s)</input_port>
<input_port name="v_x"
default="0.000000"
type="double">Linear X velocity (m/s)</input_port>
<input_port name="duration"
type="std::chrono::milliseconds">Publish then sleep duration in milliseconds</input_port>
<input_port name="topic_name"
default="__default__placeholder__"
type="std::string">Topic name</input_port>
</Action>
</TreeNodesModel>
</root>