mirror of
https://gitee.com/dlmu-cone/Arborflow
synced 2026-10-24 02:02:19 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd605877cc | |||
| 77740bf45f | |||
| 2bdd58108a |
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "arborflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "A cross-platform behavior tree editor for BehaviorTree.CPP",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
191
src/App.tsx
191
src/App.tsx
@@ -26,16 +26,20 @@ import {
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
MonitorDot,
|
||||
Moon,
|
||||
Redo2,
|
||||
Save,
|
||||
Settings,
|
||||
Sun,
|
||||
TreePine,
|
||||
Undo2,
|
||||
Upload,
|
||||
FileUp,
|
||||
} from 'lucide-react'
|
||||
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)
|
||||
@@ -84,7 +94,7 @@ function Editor() {
|
||||
try {
|
||||
return { value: documentToXml(treeDocument), error: '' }
|
||||
} catch (error) {
|
||||
return { value: '', error: error instanceof Error ? error.message : '无法生成 XML' }
|
||||
return { value: '', error: error instanceof Error ? error.message : 'Cannot generate XML' }
|
||||
}
|
||||
}, [treeDocument])
|
||||
|
||||
@@ -148,7 +158,17 @@ function Editor() {
|
||||
const addNodeAt = useCallback((type: NodeType, position?: { x: number; y: number }) => {
|
||||
const point = position || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
||||
const node = createNode(type, point)
|
||||
commit((current) => ({ ...current, nodes: [...current.nodes, node] }))
|
||||
commit((current) => {
|
||||
const next = { ...current, nodes: [...current.nodes, node] }
|
||||
// Auto-create a tree for new SubTree references
|
||||
if (type === 'SubTree') {
|
||||
const treeId = node.data.registrationName
|
||||
if (treeId && !next.trees?.some((t) => t.id === treeId)) {
|
||||
next.trees = [...(next.trees || []), { id: treeId, nodes: [], edges: [] }]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setSelectedNodeId(node.id)
|
||||
setViewMode('canvas')
|
||||
}, [commit, screenToFlowPosition])
|
||||
@@ -163,6 +183,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)
|
||||
@@ -179,6 +206,16 @@ function Editor() {
|
||||
window.setTimeout(() => fitView({ padding: 0.2, duration: 320 }), 60)
|
||||
}, [currentTreeId, fitView])
|
||||
|
||||
const navigateToTree = useCallback((treeId: string) => {
|
||||
commit((current) => {
|
||||
if (!current.trees?.some((t) => t.id === treeId)) {
|
||||
return { ...current, trees: [...(current.trees || []), { id: treeId, nodes: [], edges: [] }] }
|
||||
}
|
||||
return current
|
||||
})
|
||||
switchTree(treeId)
|
||||
}, [commit, switchTree])
|
||||
|
||||
const deleteNode = useCallback((nodeId: string) => {
|
||||
commit((current) => ({
|
||||
...current,
|
||||
@@ -194,16 +231,25 @@ function Editor() {
|
||||
}, [commit])
|
||||
|
||||
const updateNode = useCallback((nodeId: string, data: Partial<BehaviorNode['data']>) => {
|
||||
commit((current) => ({
|
||||
commit((current) => {
|
||||
const node = current.nodes.find((n) => n.id === nodeId)
|
||||
const next = {
|
||||
...current,
|
||||
nodes: current.nodes.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node),
|
||||
}))
|
||||
nodes: current.nodes.map((n) => n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n),
|
||||
}
|
||||
// Auto-create tree when SubTree registrationName changes
|
||||
if (node && node.data.nodeType === 'SubTree' && data.registrationName) {
|
||||
const treeId = data.registrationName
|
||||
if (!next.trees?.some((t) => t.id === treeId)) {
|
||||
next.trees = [...(next.trees || []), { id: treeId, nodes: [], edges: [] }]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [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) => {
|
||||
@@ -213,15 +259,15 @@ function Editor() {
|
||||
const outgoingCount = treeDocument.edges.filter((edge) => edge.source === source.id).length
|
||||
const incomingCount = treeDocument.edges.filter((edge) => edge.target === connection.target).length
|
||||
if (outgoingCount >= maxChildren(source.data.nodeType)) {
|
||||
showToast(`${source.data.label} 不能再添加子节点`, 'error')
|
||||
showToast(`${source.data.label} ${t('validation.cannotAddChild')}`, 'error')
|
||||
return
|
||||
}
|
||||
if (incomingCount > 0) {
|
||||
showToast('一个节点只能有一个父节点', 'error')
|
||||
showToast(t('validation.singleParent'), 'error')
|
||||
return
|
||||
}
|
||||
if (wouldCreateCycle(treeDocument, connection.source, connection.target)) {
|
||||
showToast('此连接会形成环路', 'error')
|
||||
showToast(t('validation.wouldCycle'), 'error')
|
||||
return
|
||||
}
|
||||
commit((current) => ({
|
||||
@@ -237,7 +283,7 @@ function Editor() {
|
||||
}, [commit, fitView, treeDocument.nodes.length])
|
||||
|
||||
const newProject = useCallback(() => {
|
||||
if (dirty && !window.confirm('当前工程有未保存更改,仍要新建吗?')) return
|
||||
if (dirty && !window.confirm(t('confirm.unsavedNew'))) return
|
||||
replaceDocument(createEmptyDocument())
|
||||
}, [dirty, replaceDocument])
|
||||
|
||||
@@ -248,9 +294,9 @@ function Editor() {
|
||||
: await pickBrowserFile('.arborflow,.json')
|
||||
if (!result) return
|
||||
replaceDocument(parseProject(result.content), window.arborflow ? result.path : null)
|
||||
showToast('工程已打开')
|
||||
showToast(t('toast.projectOpened'))
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '打开工程失败', 'error')
|
||||
showToast(error instanceof Error ? error.message : t('toast.openFailed'), 'error')
|
||||
}
|
||||
}, [replaceDocument, showToast])
|
||||
|
||||
@@ -261,7 +307,7 @@ function Editor() {
|
||||
downloadText(`${treeDocument.title || 'behavior-tree'}.arborflow`, content, 'application/json')
|
||||
savedContentRef.current = content
|
||||
setDirty(false)
|
||||
showToast('工程已下载')
|
||||
showToast(t('toast.projectDownloaded'))
|
||||
return
|
||||
}
|
||||
const result = await window.arborflow.saveText({
|
||||
@@ -275,9 +321,9 @@ function Editor() {
|
||||
setProjectPath(result.path)
|
||||
savedContentRef.current = content
|
||||
setDirty(false)
|
||||
showToast('工程已保存')
|
||||
showToast(t('toast.projectSaved'))
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '保存失败', 'error')
|
||||
showToast(error instanceof Error ? error.message : t('toast.saveFailed'), 'error')
|
||||
}
|
||||
}, [projectPath, showToast, treeDocument])
|
||||
|
||||
@@ -290,9 +336,9 @@ function Editor() {
|
||||
replaceDocument(xmlToDocument(result.content))
|
||||
savedContentRef.current = ''
|
||||
setDirty(true)
|
||||
showToast('XML 已导入')
|
||||
showToast(t('toast.xmlImported'))
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '导入 XML 失败', 'error')
|
||||
showToast(error instanceof Error ? error.message : t('toast.importFailed'), 'error')
|
||||
}
|
||||
}, [replaceDocument, showToast])
|
||||
|
||||
@@ -306,9 +352,9 @@ function Editor() {
|
||||
const result = await window.arborflow.saveText({ prompt: true, defaultPath: filename, content, filters: XML_FILTERS })
|
||||
if (!result) return
|
||||
}
|
||||
showToast('BehaviorTree.CPP XML 已导出')
|
||||
showToast(t('toast.xmlExported'))
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '导出 XML 失败', 'error')
|
||||
showToast(error instanceof Error ? error.message : t('toast.exportFailed'), 'error')
|
||||
}
|
||||
}, [showToast, treeDocument])
|
||||
|
||||
@@ -335,7 +381,7 @@ function Editor() {
|
||||
const copyXml = useCallback(async () => {
|
||||
if (!xmlPreview.value) return
|
||||
await navigator.clipboard.writeText(xmlPreview.value)
|
||||
showToast('XML 已复制')
|
||||
showToast(t('toast.xmlCopied'))
|
||||
}, [showToast, xmlPreview.value])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -434,21 +480,43 @@ function Editor() {
|
||||
<div className="brand-mark"><TreePine size={20} /></div>
|
||||
<div className="brand-copy"><strong>ArborFlow</strong><span>Behavior Tree Studio</span></div>
|
||||
</div>
|
||||
<div className="document-title"><span>{treeDocument.title}</span>{dirty && <i title="未保存" />}</div>
|
||||
<div className="document-title"><span>{treeDocument.title}</span>{dirty && <i title={t('action.unsaved')} />}</div>
|
||||
<div className="header-actions">
|
||||
<button className="icon-button" onClick={newProject} title="新建工程 (Ctrl+N)"><FilePlus2 size={17} /></button>
|
||||
<button className="icon-button" onClick={() => void openProject()} title="打开工程 (Ctrl+O)"><FolderOpen size={17} /></button>
|
||||
<button className="icon-button" onClick={() => void saveProject(false)} title="保存工程 (Ctrl+S)"><Save size={17} /></button>
|
||||
<button className="icon-button" onClick={newProject} title={`${t('action.newProject')} (Ctrl+N)`}><FilePlus2 size={17} /></button>
|
||||
<button className="icon-button" onClick={() => void openProject()} title={`${t('action.openProject')} (Ctrl+O)`}><FolderOpen size={17} /></button>
|
||||
<button className="icon-button" onClick={() => void saveProject(false)} title={`${t('action.saveProject')} (Ctrl+S)`}><Save size={17} /></button>
|
||||
<span className="toolbar-separator" />
|
||||
<button className="icon-button" onClick={undo} disabled={!canUndo} title="撤销 (Ctrl+Z)"><Undo2 size={17} /></button>
|
||||
<button className="icon-button" onClick={redo} disabled={!canRedo} title="重做 (Ctrl+Shift+Z)"><Redo2 size={17} /></button>
|
||||
<button className="icon-button" onClick={undo} disabled={!canUndo} title={`${t('action.undo')} (Ctrl+Z)`}><Undo2 size={17} /></button>
|
||||
<button className="icon-button" onClick={redo} disabled={!canRedo} title={`${t('action.redo')} (Ctrl+Shift+Z)`}><Redo2 size={17} /></button>
|
||||
<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>
|
||||
<button className="icon-button" onClick={importXml} title={t('action.importXml')}><FileUp size={17} /></button>
|
||||
<button className="icon-button" onClick={exportXml} title={t('action.exportXml')}><FileDown size={17} /></button>
|
||||
<span className="toolbar-separator" />
|
||||
<button className={`icon-button ${settingsOpen ? 'active' : ''}`} onClick={() => setSettingsOpen((v) => !v)} title={t('action.settings')}><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)}>
|
||||
@@ -456,27 +524,27 @@ function Editor() {
|
||||
</button>
|
||||
{treeMenuOpen && (
|
||||
<div className="tree-menu">
|
||||
<div className="tree-menu-heading">行为树 · {treeGraphs.length}</div>
|
||||
<div className="tree-menu-heading">{t('tree.heading')} · {treeGraphs.length}</div>
|
||||
{treeGraphs.map((tree) => (
|
||||
<button className={tree.id === currentTreeId ? 'active' : ''} key={tree.id} onClick={() => switchTree(tree.id)}>
|
||||
<span>{tree.id}</span>
|
||||
<small>{tree.nodes.length} 节点{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
|
||||
<small>{tree.nodes.length} {t('status.nodes')}{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="view-switch" aria-label="视图切换">
|
||||
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button>
|
||||
<div className="view-switch" aria-label={t('toolbar.viewSwitch')}>
|
||||
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />{t('toolbar.canvas')}</button>
|
||||
<button className={viewMode === 'xml' ? 'active' : ''} onClick={() => setViewMode('xml')}><Code2 size={14} />XML</button>
|
||||
</div>
|
||||
<div className="workspace-toolbar-actions">
|
||||
<button className="tool-button" onClick={autoLayout} title="自动布局 (Ctrl+L)"><AlignVerticalSpaceAround size={15} />自动布局</button>
|
||||
<button className="tool-button" onClick={autoLayout} title={`${t('toolbar.autoLayout')} (Ctrl+L)`}><AlignVerticalSpaceAround size={15} />{t('toolbar.autoLayout')}</button>
|
||||
</div>
|
||||
</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 +555,22 @@ function Editor() {
|
||||
onNodesChange={onNodesChange}
|
||||
onConnect={connectNodes}
|
||||
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
|
||||
onNodeDoubleClick={(_event, node) => {
|
||||
if (node.data.nodeType === 'SubTree') {
|
||||
const targetId = node.data.registrationName
|
||||
commit((current) => {
|
||||
if (!current.trees?.some((t) => t.id === targetId)) {
|
||||
return { ...current, trees: [...(current.trees || []), { id: targetId, nodes: [], edges: [] }] }
|
||||
}
|
||||
return current
|
||||
})
|
||||
if (targetId) switchTree(targetId)
|
||||
return
|
||||
}
|
||||
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 +592,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,19 +600,22 @@ 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>
|
||||
) : (
|
||||
<div className="xml-view">
|
||||
<div className="xml-header">
|
||||
<div><FileCode2 size={16} /><span>{treeDocument.mainTreeId}.xml</span></div>
|
||||
<button className="icon-button compact" onClick={copyXml} disabled={!xmlPreview.value} title="复制 XML"><Copy size={15} /></button>
|
||||
<button className="icon-button compact" onClick={copyXml} disabled={!xmlPreview.value} title={t('xml.copyButton')}><Copy size={15} /></button>
|
||||
</div>
|
||||
{xmlPreview.error ? (
|
||||
<div className="xml-error"><Activity size={20} /><strong>无法生成 XML</strong><span>{xmlPreview.error}</span></div>
|
||||
<div className="xml-error"><Activity size={20} /><strong>{t('xml.errorHeading')}</strong><span>{xmlPreview.error}</span></div>
|
||||
) : (
|
||||
<pre><code>{xmlPreview.value}</code></pre>
|
||||
)}
|
||||
@@ -553,14 +638,28 @@ function Editor() {
|
||||
onUpdateNode={updateNode}
|
||||
onDeleteNode={deleteNode}
|
||||
onSelectIssue={selectAndRevealNode}
|
||||
onNavigateToTree={navigateToTree}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="status-bar">
|
||||
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</span></div>
|
||||
<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>
|
||||
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} ${t('status.errors')}` : <><Check size={12} />{t('status.valid')}</>}</span></div>
|
||||
<div><span>{treeGraphs.length} {t('status.trees')}</span><span>{treeDocument.nodes.length} {t('status.nodes')}</span><span>{treeDocument.edges.length} {t('status.edges')}</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : t('status.webPreview')}</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,90 @@
|
||||
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} />
|
||||
)}
|
||||
|
||||
<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 — only if node can have children */}
|
||||
{canHaveChildren(data.nodeType) && (
|
||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||
)}
|
||||
|
||||
@@ -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 { AlertCircle, Braces, ExternalLink, Plus, Trash2, X } from 'lucide-react'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
|
||||
|
||||
interface InspectorProps {
|
||||
document: BehaviorTreeDocument
|
||||
@@ -10,9 +10,12 @@ 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) => {
|
||||
if (!selectedNode) return
|
||||
const ports = { ...selectedNode.data.ports }
|
||||
@@ -21,11 +24,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 +55,111 @@ 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>
|
||||
{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} />端口与黑板</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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
202
src/components/NodeDetailModal.tsx
Normal file
202
src/components/NodeDetailModal.tsx
Normal 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, lang } = 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)' }}>
|
||||
{lang === 'zh' && definition?.labelZh ? 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
<span className="eyebrow">{t('palette.eyebrow')}</span>
|
||||
<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="搜索节点" />
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
|
||||
<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={t('palette.clearSearch')}><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)} · ${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>
|
||||
@@ -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={`${nodeLabel(item)} — ${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>
|
||||
)
|
||||
|
||||
52
src/context/I18nContext.tsx
Normal file
52
src/context/I18nContext.tsx
Normal 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
|
||||
}
|
||||
43
src/context/ThemeContext.tsx
Normal file
43
src/context/ThemeContext.tsx
Normal 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
|
||||
}
|
||||
189
src/i18n/en.ts
Normal file
189
src/i18n/en.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
const en: Record<string, string> = {
|
||||
/* ── App Chrome ── */
|
||||
'app.subtitle': 'Behavior Tree Studio',
|
||||
|
||||
/* ── Toolbar ── */
|
||||
'toolbar.canvas': 'Canvas',
|
||||
'toolbar.xml': 'XML',
|
||||
'toolbar.autoLayout': 'Auto Layout',
|
||||
'toolbar.viewSwitch': 'View Switch',
|
||||
'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.unsaved': 'Unsaved',
|
||||
'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.eyebrow': 'LIBRARY',
|
||||
'palette.noMatch': 'No matching nodes',
|
||||
'palette.clearSearch': 'Clear search',
|
||||
'palette.portCount': '{count} port(s)',
|
||||
'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.enterSubtree': 'Enter Subtree',
|
||||
'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',
|
||||
'xml.errorHeading': 'Cannot Generate 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.clearRuntime': 'Clear Runtime Status',
|
||||
'monitor.closeMonitor': 'Close Monitor',
|
||||
'monitor.matched': 'Matched',
|
||||
}
|
||||
|
||||
export default en
|
||||
189
src/i18n/zh.ts
Normal file
189
src/i18n/zh.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
const zh: Record<string, string> = {
|
||||
/* ── App Chrome ── */
|
||||
'app.subtitle': '行为树编辑器',
|
||||
|
||||
/* ── Toolbar ── */
|
||||
'toolbar.canvas': '画布',
|
||||
'toolbar.xml': 'XML',
|
||||
'toolbar.autoLayout': '自动布局',
|
||||
'toolbar.viewSwitch': '视图切换',
|
||||
'toolbar.monitor': 'Monitor',
|
||||
|
||||
/* ── Header Actions ── */
|
||||
'action.newProject': '新建工程',
|
||||
'action.openProject': '打开工程',
|
||||
'action.saveProject': '保存工程',
|
||||
'action.undo': '撤销',
|
||||
'action.redo': '重做',
|
||||
'action.importXml': '导入 XML',
|
||||
'action.exportXml': '导出 XML',
|
||||
'action.unsaved': '未保存',
|
||||
'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.eyebrow': '节点库',
|
||||
'palette.noMatch': '没有匹配节点',
|
||||
'palette.clearSearch': '清除搜索',
|
||||
'palette.portCount': '{count} 个端口',
|
||||
'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.enterSubtree': '进入子树',
|
||||
'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',
|
||||
'xml.errorHeading': '无法生成 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.clearRuntime': '清空运行状态',
|
||||
'monitor.closeMonitor': '关闭监视器',
|
||||
'monitor.matched': '匹配',
|
||||
}
|
||||
|
||||
export default zh
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { createNode } from './catalog'
|
||||
import { syncActiveTree, switchDocumentTree } from './trees'
|
||||
import type { BehaviorTreeDocument } from '../types'
|
||||
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument } from '../types'
|
||||
|
||||
export function serializeProject(document: BehaviorTreeDocument) {
|
||||
return JSON.stringify(syncActiveTree(document), null, 2) + '\n'
|
||||
}
|
||||
|
||||
/** Ensure the active tree has a Root node at the top. If not, add one. */
|
||||
export function ensureRootNode(nodes: BehaviorNode[], edges: BehaviorEdge[]): { nodes: BehaviorNode[]; edges: BehaviorEdge[] } {
|
||||
const hasRoot = nodes.some((n) => n.data.nodeType === 'Root')
|
||||
if (hasRoot) return { nodes, edges }
|
||||
|
||||
const incoming = new Map<string, number>()
|
||||
edges.forEach((e) => incoming.set(e.target, (incoming.get(e.target) || 0) + 1))
|
||||
const roots = nodes.filter((n) => (incoming.get(n.id) || 0) === 0)
|
||||
if (roots.length === 0) return { nodes, edges }
|
||||
|
||||
const rootNode = createNode('Root')
|
||||
rootNode.data = { ...rootNode.data, label: 'Root', registrationName: 'Root' }
|
||||
|
||||
let edgeIndex = edges.length
|
||||
const newEdges: BehaviorEdge[] = [...edges]
|
||||
roots.forEach((r) => {
|
||||
newEdges.push({ id: `edge_root_${edgeIndex++}`, source: rootNode.id, target: r.id, type: 'smoothstep' })
|
||||
})
|
||||
|
||||
return { nodes: [rootNode, ...nodes], edges: newEdges }
|
||||
}
|
||||
|
||||
export function parseProject(content: string): BehaviorTreeDocument {
|
||||
const value: unknown = JSON.parse(content)
|
||||
if (!value || typeof value !== 'object') throw new Error('工程文件内容无效')
|
||||
@@ -16,6 +39,9 @@ export function parseProject(content: string): BehaviorTreeDocument {
|
||||
throw new Error('工程文件缺少节点或连接数据')
|
||||
}
|
||||
const document = candidate as BehaviorTreeDocument
|
||||
const { nodes, edges } = ensureRootNode(document.nodes, document.edges)
|
||||
document.nodes = nodes
|
||||
document.edges = edges
|
||||
const requestedTree = document.activeTreeId || document.mainTreeId
|
||||
return document.trees?.length ? switchDocumentTree(document, requestedTree) : document
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
@@ -187,6 +200,26 @@ function parseBehaviorTree(element: Element, models: Map<string, CustomNodeModel
|
||||
Array.from(nodeElement.children).forEach((child) => visit(child, node.id))
|
||||
}
|
||||
visit(treeRoot)
|
||||
|
||||
// Ensure a Root node sits at the top of every imported tree
|
||||
const incoming = new Map(nodes.map((n) => [n.id, 0]))
|
||||
edges.forEach((e) => incoming.set(e.target, (incoming.get(e.target) || 0) + 1))
|
||||
const roots = nodes.filter((n) => (incoming.get(n.id) || 0) === 0)
|
||||
const hasRootNode = roots.some((n) => n.data.nodeType === 'Root')
|
||||
|
||||
if (!hasRootNode && roots.length > 0) {
|
||||
const rootNode = createNode('Root')
|
||||
rootNode.data = {
|
||||
...rootNode.data,
|
||||
label: treeId,
|
||||
registrationName: treeId,
|
||||
}
|
||||
nodes.push(rootNode)
|
||||
roots.forEach((r) => {
|
||||
edges.push({ id: `edge_${treeId}_${edgeIndex++}`, source: rootNode.id, target: r.id, type: 'smoothstep' })
|
||||
})
|
||||
}
|
||||
|
||||
return layoutGraph({ id: treeId, nodes, edges })
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
895
src/styles.css
895
src/styles.css
File diff suppressed because it is too large
Load Diff
@@ -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
144
test_rmul_2025.xml
Normal 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_<std::allocator<void> >">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_<std::allocator<void> >">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_<std::allocator<void> >">RobotStatus port on blackboard</input_port>
|
||||
</Condition>
|
||||
<Condition ID="PubNav2Goal">
|
||||
<input_port name="goal"
|
||||
default="0;0;0"
|
||||
type="geometry_msgs::msg::PoseStamped_<std::allocator<void> >">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>
|
||||
Reference in New Issue
Block a user