mirror of
https://gitee.com/dlmu-cone/Arborflow
synced 2026-10-23 09:42:19 +08:00
feat: subtree navigation, i18n cleanup, UI polish (centered node text, smooth theme transition, remapped icons)
This commit is contained in:
125
src/App.tsx
125
src/App.tsx
@@ -33,7 +33,7 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
TreePine,
|
TreePine,
|
||||||
Undo2,
|
Undo2,
|
||||||
Upload,
|
FileUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BehaviorNodeCard } from './components/BehaviorNodeCard'
|
import { BehaviorNodeCard } from './components/BehaviorNodeCard'
|
||||||
@@ -94,7 +94,7 @@ function Editor() {
|
|||||||
try {
|
try {
|
||||||
return { value: documentToXml(treeDocument), error: '' }
|
return { value: documentToXml(treeDocument), error: '' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { value: '', error: error instanceof Error ? error.message : '无法生成 XML' }
|
return { value: '', error: error instanceof Error ? error.message : 'Cannot generate XML' }
|
||||||
}
|
}
|
||||||
}, [treeDocument])
|
}, [treeDocument])
|
||||||
|
|
||||||
@@ -158,7 +158,17 @@ function Editor() {
|
|||||||
const addNodeAt = useCallback((type: NodeType, position?: { x: number; y: number }) => {
|
const addNodeAt = useCallback((type: NodeType, position?: { x: number; y: number }) => {
|
||||||
const point = position || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
const point = position || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
||||||
const node = createNode(type, point)
|
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)
|
setSelectedNodeId(node.id)
|
||||||
setViewMode('canvas')
|
setViewMode('canvas')
|
||||||
}, [commit, screenToFlowPosition])
|
}, [commit, screenToFlowPosition])
|
||||||
@@ -196,6 +206,16 @@ function Editor() {
|
|||||||
window.setTimeout(() => fitView({ padding: 0.2, duration: 320 }), 60)
|
window.setTimeout(() => fitView({ padding: 0.2, duration: 320 }), 60)
|
||||||
}, [currentTreeId, fitView])
|
}, [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) => {
|
const deleteNode = useCallback((nodeId: string) => {
|
||||||
commit((current) => ({
|
commit((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -211,10 +231,21 @@ function Editor() {
|
|||||||
}, [commit])
|
}, [commit])
|
||||||
|
|
||||||
const updateNode = useCallback((nodeId: string, data: Partial<BehaviorNode['data']>) => {
|
const updateNode = useCallback((nodeId: string, data: Partial<BehaviorNode['data']>) => {
|
||||||
commit((current) => ({
|
commit((current) => {
|
||||||
...current,
|
const node = current.nodes.find((n) => n.id === nodeId)
|
||||||
nodes: current.nodes.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node),
|
const next = {
|
||||||
}))
|
...current,
|
||||||
|
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])
|
}, [commit])
|
||||||
|
|
||||||
const onNodesChange = useCallback((changes: NodeChange<BehaviorNode>[]) => {
|
const onNodesChange = useCallback((changes: NodeChange<BehaviorNode>[]) => {
|
||||||
@@ -228,15 +259,15 @@ function Editor() {
|
|||||||
const outgoingCount = treeDocument.edges.filter((edge) => edge.source === source.id).length
|
const outgoingCount = treeDocument.edges.filter((edge) => edge.source === source.id).length
|
||||||
const incomingCount = treeDocument.edges.filter((edge) => edge.target === connection.target).length
|
const incomingCount = treeDocument.edges.filter((edge) => edge.target === connection.target).length
|
||||||
if (outgoingCount >= maxChildren(source.data.nodeType)) {
|
if (outgoingCount >= maxChildren(source.data.nodeType)) {
|
||||||
showToast(`${source.data.label} 不能再添加子节点`, 'error')
|
showToast(`${source.data.label} ${t('validation.cannotAddChild')}`, 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (incomingCount > 0) {
|
if (incomingCount > 0) {
|
||||||
showToast('一个节点只能有一个父节点', 'error')
|
showToast(t('validation.singleParent'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (wouldCreateCycle(treeDocument, connection.source, connection.target)) {
|
if (wouldCreateCycle(treeDocument, connection.source, connection.target)) {
|
||||||
showToast('此连接会形成环路', 'error')
|
showToast(t('validation.wouldCycle'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
commit((current) => ({
|
commit((current) => ({
|
||||||
@@ -252,7 +283,7 @@ function Editor() {
|
|||||||
}, [commit, fitView, treeDocument.nodes.length])
|
}, [commit, fitView, treeDocument.nodes.length])
|
||||||
|
|
||||||
const newProject = useCallback(() => {
|
const newProject = useCallback(() => {
|
||||||
if (dirty && !window.confirm('当前工程有未保存更改,仍要新建吗?')) return
|
if (dirty && !window.confirm(t('confirm.unsavedNew'))) return
|
||||||
replaceDocument(createEmptyDocument())
|
replaceDocument(createEmptyDocument())
|
||||||
}, [dirty, replaceDocument])
|
}, [dirty, replaceDocument])
|
||||||
|
|
||||||
@@ -263,9 +294,9 @@ function Editor() {
|
|||||||
: await pickBrowserFile('.arborflow,.json')
|
: await pickBrowserFile('.arborflow,.json')
|
||||||
if (!result) return
|
if (!result) return
|
||||||
replaceDocument(parseProject(result.content), window.arborflow ? result.path : null)
|
replaceDocument(parseProject(result.content), window.arborflow ? result.path : null)
|
||||||
showToast('工程已打开')
|
showToast(t('toast.projectOpened'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : '打开工程失败', 'error')
|
showToast(error instanceof Error ? error.message : t('toast.openFailed'), 'error')
|
||||||
}
|
}
|
||||||
}, [replaceDocument, showToast])
|
}, [replaceDocument, showToast])
|
||||||
|
|
||||||
@@ -276,7 +307,7 @@ function Editor() {
|
|||||||
downloadText(`${treeDocument.title || 'behavior-tree'}.arborflow`, content, 'application/json')
|
downloadText(`${treeDocument.title || 'behavior-tree'}.arborflow`, content, 'application/json')
|
||||||
savedContentRef.current = content
|
savedContentRef.current = content
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
showToast('工程已下载')
|
showToast(t('toast.projectDownloaded'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await window.arborflow.saveText({
|
const result = await window.arborflow.saveText({
|
||||||
@@ -290,9 +321,9 @@ function Editor() {
|
|||||||
setProjectPath(result.path)
|
setProjectPath(result.path)
|
||||||
savedContentRef.current = content
|
savedContentRef.current = content
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
showToast('工程已保存')
|
showToast(t('toast.projectSaved'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : '保存失败', 'error')
|
showToast(error instanceof Error ? error.message : t('toast.saveFailed'), 'error')
|
||||||
}
|
}
|
||||||
}, [projectPath, showToast, treeDocument])
|
}, [projectPath, showToast, treeDocument])
|
||||||
|
|
||||||
@@ -305,9 +336,9 @@ function Editor() {
|
|||||||
replaceDocument(xmlToDocument(result.content))
|
replaceDocument(xmlToDocument(result.content))
|
||||||
savedContentRef.current = ''
|
savedContentRef.current = ''
|
||||||
setDirty(true)
|
setDirty(true)
|
||||||
showToast('XML 已导入')
|
showToast(t('toast.xmlImported'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : '导入 XML 失败', 'error')
|
showToast(error instanceof Error ? error.message : t('toast.importFailed'), 'error')
|
||||||
}
|
}
|
||||||
}, [replaceDocument, showToast])
|
}, [replaceDocument, showToast])
|
||||||
|
|
||||||
@@ -321,9 +352,9 @@ function Editor() {
|
|||||||
const result = await window.arborflow.saveText({ prompt: true, defaultPath: filename, content, filters: XML_FILTERS })
|
const result = await window.arborflow.saveText({ prompt: true, defaultPath: filename, content, filters: XML_FILTERS })
|
||||||
if (!result) return
|
if (!result) return
|
||||||
}
|
}
|
||||||
showToast('BehaviorTree.CPP XML 已导出')
|
showToast(t('toast.xmlExported'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : '导出 XML 失败', 'error')
|
showToast(error instanceof Error ? error.message : t('toast.exportFailed'), 'error')
|
||||||
}
|
}
|
||||||
}, [showToast, treeDocument])
|
}, [showToast, treeDocument])
|
||||||
|
|
||||||
@@ -350,7 +381,7 @@ function Editor() {
|
|||||||
const copyXml = useCallback(async () => {
|
const copyXml = useCallback(async () => {
|
||||||
if (!xmlPreview.value) return
|
if (!xmlPreview.value) return
|
||||||
await navigator.clipboard.writeText(xmlPreview.value)
|
await navigator.clipboard.writeText(xmlPreview.value)
|
||||||
showToast('XML 已复制')
|
showToast(t('toast.xmlCopied'))
|
||||||
}, [showToast, xmlPreview.value])
|
}, [showToast, xmlPreview.value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -449,19 +480,19 @@ function Editor() {
|
|||||||
<div className="brand-mark"><TreePine size={20} /></div>
|
<div className="brand-mark"><TreePine size={20} /></div>
|
||||||
<div className="brand-copy"><strong>ArborFlow</strong><span>Behavior Tree Studio</span></div>
|
<div className="brand-copy"><strong>ArborFlow</strong><span>Behavior Tree Studio</span></div>
|
||||||
</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">
|
<div className="header-actions">
|
||||||
<button className="icon-button" onClick={newProject} title="新建工程 (Ctrl+N)"><FilePlus2 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="打开工程 (Ctrl+O)"><FolderOpen 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="保存工程 (Ctrl+S)"><Save 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" />
|
<span className="toolbar-separator" />
|
||||||
<button className="icon-button" onClick={undo} disabled={!canUndo} title="撤销 (Ctrl+Z)"><Undo2 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="重做 (Ctrl+Shift+Z)"><Redo2 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" />
|
<span className="toolbar-separator" />
|
||||||
<button className="icon-button" onClick={importXml} title="导入 XML"><Upload size={17} /></button>
|
<button className="icon-button" onClick={importXml} title={t('action.importXml')}><FileUp size={17} /></button>
|
||||||
<button className="icon-button" onClick={exportXml} title="导出 XML"><FileDown size={17} /></button>
|
<button className="icon-button" onClick={exportXml} title={t('action.exportXml')}><FileDown size={17} /></button>
|
||||||
<span className="toolbar-separator" />
|
<span className="toolbar-separator" />
|
||||||
<button className={`icon-button ${settingsOpen ? 'active' : ''}`} onClick={() => setSettingsOpen((v) => !v)} title="设置"><Settings size={17} /></button>
|
<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>
|
<button className={`monitor-button ${monitorOpen ? 'active' : ''}`} onClick={() => setMonitorOpen((value) => !value)}><MonitorDot size={16} />Monitor</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -493,22 +524,22 @@ function Editor() {
|
|||||||
</button>
|
</button>
|
||||||
{treeMenuOpen && (
|
{treeMenuOpen && (
|
||||||
<div className="tree-menu">
|
<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) => (
|
{treeGraphs.map((tree) => (
|
||||||
<button className={tree.id === currentTreeId ? 'active' : ''} key={tree.id} onClick={() => switchTree(tree.id)}>
|
<button className={tree.id === currentTreeId ? 'active' : ''} key={tree.id} onClick={() => switchTree(tree.id)}>
|
||||||
<span>{tree.id}</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="view-switch" aria-label="视图切换">
|
<div className="view-switch" aria-label={t('toolbar.viewSwitch')}>
|
||||||
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button>
|
<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>
|
<button className={viewMode === 'xml' ? 'active' : ''} onClick={() => setViewMode('xml')}><Code2 size={14} />XML</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-toolbar-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -524,7 +555,20 @@ function Editor() {
|
|||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onConnect={connectNodes}
|
onConnect={connectNodes}
|
||||||
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
|
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
|
||||||
onNodeDoubleClick={(_event, node) => { setModalNodeId(node.id); setSelectedNodeId(node.id) }}
|
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) }}
|
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }}
|
||||||
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }}
|
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }}
|
||||||
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
|
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
|
||||||
@@ -568,10 +612,10 @@ function Editor() {
|
|||||||
<div className="xml-view">
|
<div className="xml-view">
|
||||||
<div className="xml-header">
|
<div className="xml-header">
|
||||||
<div><FileCode2 size={16} /><span>{treeDocument.mainTreeId}.xml</span></div>
|
<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>
|
</div>
|
||||||
{xmlPreview.error ? (
|
{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>
|
<pre><code>{xmlPreview.value}</code></pre>
|
||||||
)}
|
)}
|
||||||
@@ -594,12 +638,13 @@ function Editor() {
|
|||||||
onUpdateNode={updateNode}
|
onUpdateNode={updateNode}
|
||||||
onDeleteNode={deleteNode}
|
onDeleteNode={deleteNode}
|
||||||
onSelectIssue={selectAndRevealNode}
|
onSelectIssue={selectAndRevealNode}
|
||||||
|
onNavigateToTree={navigateToTree}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="status-bar">
|
<footer className="status-bar">
|
||||||
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</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} 棵树</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>{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>
|
</footer>
|
||||||
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
|
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
|
|||||||
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
|
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
|
||||||
{/* Top handle — skip for Root node since nothing connects above it */}
|
{/* Top handle — skip for Root node since nothing connects above it */}
|
||||||
{!isRoot && (
|
{!isRoot && (
|
||||||
<>
|
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
||||||
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
|
||||||
<span className="handle-label handle-label-in">IN</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="node-accent" />
|
<div className="node-accent" />
|
||||||
@@ -87,12 +84,9 @@ export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
|
|||||||
{/* Root badge */}
|
{/* Root badge */}
|
||||||
{isRoot && <span className="runtime-badge" style={{ color: 'var(--amber)', borderColor: 'var(--amber)', bottom: 'auto', top: 6, right: 7 }}>ROOT</span>}
|
{isRoot && <span className="runtime-badge" style={{ color: 'var(--amber)', borderColor: 'var(--amber)', bottom: 'auto', top: 6, right: 7 }}>ROOT</span>}
|
||||||
|
|
||||||
{/* Bottom handle with OUT label — only if node can have children */}
|
{/* Bottom handle — only if node can have children */}
|
||||||
{canHaveChildren(data.nodeType) && (
|
{canHaveChildren(data.nodeType) && (
|
||||||
<>
|
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
|
||||||
<span className="handle-label handle-label-out">OUT</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
|
import { AlertCircle, Braces, ExternalLink, Plus, Trash2, X } from 'lucide-react'
|
||||||
import { useI18n } from '../context/I18nContext'
|
import { useI18n } from '../context/I18nContext'
|
||||||
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
|
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
|
||||||
|
|
||||||
@@ -10,9 +10,10 @@ interface InspectorProps {
|
|||||||
onUpdateNode: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
|
onUpdateNode: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
|
||||||
onDeleteNode: (nodeId: string) => void
|
onDeleteNode: (nodeId: string) => void
|
||||||
onSelectIssue: (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 { t } = useI18n()
|
||||||
|
|
||||||
const updatePort = (oldKey: string, key: string, value: string) => {
|
const updatePort = (oldKey: string, key: string, value: string) => {
|
||||||
@@ -94,6 +95,11 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
|
|||||||
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
|
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
|
||||||
</div>
|
</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-divider" />
|
||||||
<div className="section-title-row">
|
<div className="section-title-row">
|
||||||
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
|
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Activity, CircleStop, PlugZap, RotateCw, Trash2, X } from 'lucide-react'
|
import { Activity, CircleStop, PlugZap, RotateCw, Trash2, X } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { matchRuntimeNodes, parseMonitorMessage } from '../lib/runtime'
|
import { matchRuntimeNodes, parseMonitorMessage } from '../lib/runtime'
|
||||||
|
import { useI18n } from '../context/I18nContext'
|
||||||
import type { BehaviorNode, RuntimeEvent, RuntimeStatus } from '../types'
|
import type { BehaviorNode, RuntimeEvent, RuntimeStatus } from '../types'
|
||||||
|
|
||||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
@@ -13,11 +14,11 @@ interface MonitorPanelProps {
|
|||||||
onClearStatuses: () => void
|
onClearStatuses: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATE_LABEL: Record<ConnectionState, string> = {
|
|
||||||
disconnected: '未连接', connecting: '连接中', connected: '已连接', error: '连接错误',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearStatuses }: MonitorPanelProps) {
|
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 [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 [rosTopic, setRosTopic] = useState(() => localStorage.getItem('arborflow.monitor.topic') || '/arborflow/status')
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
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-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-title"><Activity size={17} /><strong>Real-time Monitor</strong><span className={`connection-pill ${connectionState}`}><i />{STATE_LABEL[connectionState]}</span></div>
|
||||||
<div className="monitor-actions">
|
<div className="monitor-actions">
|
||||||
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title="清空运行状态"><Trash2 size={15} /></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="关闭监视器"><X size={16} /></button>
|
<button className="icon-button compact" onClick={onClose} title={t('monitor.closeMonitor')}><X size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="monitor-body">
|
<div className="monitor-body">
|
||||||
<div className="monitor-connect">
|
<div className="monitor-connect">
|
||||||
<div className="field-group">
|
<div className="field-group">
|
||||||
<label htmlFor="monitor-endpoint">ROS WebSocket 地址</label>
|
<label htmlFor="monitor-endpoint">{t('monitor.endpoint')}</label>
|
||||||
<div className="endpoint-row">
|
<div className="endpoint-row">
|
||||||
<input id="monitor-endpoint" className="mono" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} disabled={shouldConnect} />
|
<input id="monitor-endpoint" className="mono" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} disabled={shouldConnect} />
|
||||||
{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>
|
||||||
</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} />
|
<input id="monitor-topic" className="mono" value={rosTopic} onChange={(event) => setRosTopic(event.target.value)} disabled={shouldConnect} />
|
||||||
</div>
|
</div>
|
||||||
<label className="toggle-row compact-toggle">
|
<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)} />
|
<input type="checkbox" checked={autoReconnect} onChange={(event) => setAutoReconnect(event.target.checked)} />
|
||||||
</label>
|
</label>
|
||||||
<div className="monitor-metrics">
|
<div className="monitor-metrics">
|
||||||
<div><span>消息</span><strong>{messageCount}</strong></div>
|
<div><span>{t('monitor.messages')}</span><strong>{messageCount}</strong></div>
|
||||||
<div><span>延迟</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
|
<div><span>{t('monitor.latency')}</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.matched')}</span><strong>{events.filter((event) => event.matchedNodeIds.length).length}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="runtime-legend">
|
<div className="runtime-legend">
|
||||||
{(['RUNNING', 'SUCCESS', 'FAILURE', 'IDLE', 'SKIPPED'] as RuntimeStatus[]).map((status) => <span key={status}><i className={`runtime-${status.toLowerCase()}`} />{status}</span>)}
|
{(['RUNNING', 'SUCCESS', 'FAILURE', 'IDLE', 'SKIPPED'] as RuntimeStatus[]).map((status) => <span key={status}><i className={`runtime-${status.toLowerCase()}`} />{status}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="event-stream">
|
<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">
|
<div className="event-list">
|
||||||
{!events.length && <div className="event-empty">等待节点状态</div>}
|
{!events.length && <div className="event-empty">{t('monitor.waiting')}</div>}
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div className="event-row" key={event.id}>
|
<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()}`} />
|
<i className={`runtime-${event.status.toLowerCase()}`} />
|
||||||
<span className="event-node" title={event.nodeRef}>{event.nodeRef}</span>
|
<span className="event-node" title={event.nodeRef}>{event.nodeRef}</span>
|
||||||
<span className={`event-status runtime-${event.status.toLowerCase()}`}>{event.status}</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface NodeDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) {
|
export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) {
|
||||||
const { t } = useI18n()
|
const { t, lang } = useI18n()
|
||||||
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
|
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
|
||||||
|
|
||||||
// Local working copy
|
// Local working copy
|
||||||
@@ -100,7 +100,7 @@ export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailM
|
|||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>
|
<h2>
|
||||||
<span style={{ color: 'var(--category-color)' }}>
|
<span style={{ color: 'var(--category-color)' }}>
|
||||||
{definition?.labelZh && definition.labelZh !== definition.label ? definition.labelZh : definition?.label}
|
{lang === 'zh' && definition?.labelZh ? definition.labelZh : definition?.label}
|
||||||
</span>
|
</span>
|
||||||
{t('modal.editNode')}
|
{t('modal.editNode')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
|||||||
<aside className="left-panel">
|
<aside className="left-panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">LIBRARY</span>
|
<span className="eyebrow">{t('palette.eyebrow')}</span>
|
||||||
<h2>{t('palette.title')}</h2>
|
<h2>{t('palette.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
|
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
|
||||||
@@ -101,7 +101,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
|||||||
<div className="palette-search">
|
<div className="palette-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('palette.searchPlaceholder')} aria-label={t('palette.searchPlaceholder')} />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('palette.searchPlaceholder')} aria-label={t('palette.searchPlaceholder')} />
|
||||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
|
{query && <button className="bare-icon" onClick={() => setQuery('')} title={t('palette.clearSearch')}><X size={15} /></button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="palette-scroll">
|
<div className="palette-scroll">
|
||||||
{visibleModels.length > 0 && (
|
{visibleModels.length > 0 && (
|
||||||
@@ -117,7 +117,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
|||||||
key={model.id}
|
key={model.id}
|
||||||
onDragStart={(event) => beginCustomDrag(event, model.id)}
|
onDragStart={(event) => beginCustomDrag(event, model.id)}
|
||||||
onClick={() => onAddCustomNode(model.id)}
|
onClick={() => onAddCustomNode(model.id)}
|
||||||
title={`${catLabel(model.category)} · ${model.ports.length} 个端口`}
|
title={`${catLabel(model.category)} · ${t('palette.portCount').replace('{count}', String(model.ports.length))}`}
|
||||||
>
|
>
|
||||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||||
<span className="palette-item-name">{model.id}</span>
|
<span className="palette-item-name">{model.id}</span>
|
||||||
@@ -170,7 +170,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
|
|||||||
key={item.type}
|
key={item.type}
|
||||||
onDragStart={(event) => beginDrag(event, item.type)}
|
onDragStart={(event) => beginDrag(event, item.type)}
|
||||||
onClick={() => onAddNode(item.type)}
|
onClick={() => onAddNode(item.type)}
|
||||||
title={nodeDesc(item)}
|
title={`${nodeLabel(item)} — ${nodeDesc(item)}`}
|
||||||
>
|
>
|
||||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||||
<span className="palette-item-name">{nodeLabel(item)}</span>
|
<span className="palette-item-name">{nodeLabel(item)}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const en: Record<string, string> = {
|
|||||||
'toolbar.canvas': 'Canvas',
|
'toolbar.canvas': 'Canvas',
|
||||||
'toolbar.xml': 'XML',
|
'toolbar.xml': 'XML',
|
||||||
'toolbar.autoLayout': 'Auto Layout',
|
'toolbar.autoLayout': 'Auto Layout',
|
||||||
|
'toolbar.viewSwitch': 'View Switch',
|
||||||
'toolbar.monitor': 'Monitor',
|
'toolbar.monitor': 'Monitor',
|
||||||
|
|
||||||
/* ── Header Actions ── */
|
/* ── Header Actions ── */
|
||||||
@@ -16,6 +17,7 @@ const en: Record<string, string> = {
|
|||||||
'action.redo': 'Redo',
|
'action.redo': 'Redo',
|
||||||
'action.importXml': 'Import XML',
|
'action.importXml': 'Import XML',
|
||||||
'action.exportXml': 'Export XML',
|
'action.exportXml': 'Export XML',
|
||||||
|
'action.unsaved': 'Unsaved',
|
||||||
'action.settings': 'Settings',
|
'action.settings': 'Settings',
|
||||||
|
|
||||||
/* ── Settings ── */
|
/* ── Settings ── */
|
||||||
@@ -35,7 +37,10 @@ const en: Record<string, string> = {
|
|||||||
'palette.title': 'Nodes',
|
'palette.title': 'Nodes',
|
||||||
'palette.searchPlaceholder': 'Search nodes',
|
'palette.searchPlaceholder': 'Search nodes',
|
||||||
'palette.customNodes': 'XML Custom Nodes',
|
'palette.customNodes': 'XML Custom Nodes',
|
||||||
|
'palette.eyebrow': 'LIBRARY',
|
||||||
'palette.noMatch': 'No matching nodes',
|
'palette.noMatch': 'No matching nodes',
|
||||||
|
'palette.clearSearch': 'Clear search',
|
||||||
|
'palette.portCount': '{count} port(s)',
|
||||||
'palette.customModelAdd': 'Add Custom Node',
|
'palette.customModelAdd': 'Add Custom Node',
|
||||||
'palette.customModelName': 'Node Name / ID',
|
'palette.customModelName': 'Node Name / ID',
|
||||||
'palette.customModelType': 'Base Type',
|
'palette.customModelType': 'Base Type',
|
||||||
@@ -101,6 +106,7 @@ const en: Record<string, string> = {
|
|||||||
'inspector.portDirection': 'Direction',
|
'inspector.portDirection': 'Direction',
|
||||||
'inspector.breakpoint': 'Breakpoint',
|
'inspector.breakpoint': 'Breakpoint',
|
||||||
'inspector.breakpointDesc': 'Flag when execution reaches this node',
|
'inspector.breakpointDesc': 'Flag when execution reaches this node',
|
||||||
|
'inspector.enterSubtree': 'Enter Subtree',
|
||||||
'inspector.notes': 'Notes',
|
'inspector.notes': 'Notes',
|
||||||
'inspector.deleteNode': 'Delete Node',
|
'inspector.deleteNode': 'Delete Node',
|
||||||
'inspector.structureCheck': 'Structure Check',
|
'inspector.structureCheck': 'Structure Check',
|
||||||
@@ -158,6 +164,7 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
/* ── XML View ── */
|
/* ── XML View ── */
|
||||||
'xml.copyButton': 'Copy XML',
|
'xml.copyButton': 'Copy XML',
|
||||||
|
'xml.errorHeading': 'Cannot Generate XML',
|
||||||
|
|
||||||
/* ── Monitor ── */
|
/* ── Monitor ── */
|
||||||
'monitor.disconnected': 'Disconnected',
|
'monitor.disconnected': 'Disconnected',
|
||||||
@@ -174,6 +181,8 @@ const en: Record<string, string> = {
|
|||||||
'monitor.connect': 'Connect',
|
'monitor.connect': 'Connect',
|
||||||
'monitor.messages': 'Messages',
|
'monitor.messages': 'Messages',
|
||||||
'monitor.latency': 'Latency',
|
'monitor.latency': 'Latency',
|
||||||
|
'monitor.clearRuntime': 'Clear Runtime Status',
|
||||||
|
'monitor.closeMonitor': 'Close Monitor',
|
||||||
'monitor.matched': 'Matched',
|
'monitor.matched': 'Matched',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const zh: Record<string, string> = {
|
|||||||
'toolbar.canvas': '画布',
|
'toolbar.canvas': '画布',
|
||||||
'toolbar.xml': 'XML',
|
'toolbar.xml': 'XML',
|
||||||
'toolbar.autoLayout': '自动布局',
|
'toolbar.autoLayout': '自动布局',
|
||||||
|
'toolbar.viewSwitch': '视图切换',
|
||||||
'toolbar.monitor': 'Monitor',
|
'toolbar.monitor': 'Monitor',
|
||||||
|
|
||||||
/* ── Header Actions ── */
|
/* ── Header Actions ── */
|
||||||
@@ -16,6 +17,7 @@ const zh: Record<string, string> = {
|
|||||||
'action.redo': '重做',
|
'action.redo': '重做',
|
||||||
'action.importXml': '导入 XML',
|
'action.importXml': '导入 XML',
|
||||||
'action.exportXml': '导出 XML',
|
'action.exportXml': '导出 XML',
|
||||||
|
'action.unsaved': '未保存',
|
||||||
'action.settings': '设置',
|
'action.settings': '设置',
|
||||||
|
|
||||||
/* ── Settings ── */
|
/* ── Settings ── */
|
||||||
@@ -35,7 +37,10 @@ const zh: Record<string, string> = {
|
|||||||
'palette.title': '节点',
|
'palette.title': '节点',
|
||||||
'palette.searchPlaceholder': '搜索节点',
|
'palette.searchPlaceholder': '搜索节点',
|
||||||
'palette.customNodes': 'XML 自定义节点',
|
'palette.customNodes': 'XML 自定义节点',
|
||||||
|
'palette.eyebrow': '节点库',
|
||||||
'palette.noMatch': '没有匹配节点',
|
'palette.noMatch': '没有匹配节点',
|
||||||
|
'palette.clearSearch': '清除搜索',
|
||||||
|
'palette.portCount': '{count} 个端口',
|
||||||
'palette.customModelAdd': '添加自定义节点',
|
'palette.customModelAdd': '添加自定义节点',
|
||||||
'palette.customModelName': '节点名称 / ID',
|
'palette.customModelName': '节点名称 / ID',
|
||||||
'palette.customModelType': '基础类型',
|
'palette.customModelType': '基础类型',
|
||||||
@@ -101,6 +106,7 @@ const zh: Record<string, string> = {
|
|||||||
'inspector.portDirection': '方向',
|
'inspector.portDirection': '方向',
|
||||||
'inspector.breakpoint': '断点',
|
'inspector.breakpoint': '断点',
|
||||||
'inspector.breakpointDesc': '运行到此节点时标记',
|
'inspector.breakpointDesc': '运行到此节点时标记',
|
||||||
|
'inspector.enterSubtree': '进入子树',
|
||||||
'inspector.notes': '备注',
|
'inspector.notes': '备注',
|
||||||
'inspector.deleteNode': '删除节点',
|
'inspector.deleteNode': '删除节点',
|
||||||
'inspector.structureCheck': '结构检查',
|
'inspector.structureCheck': '结构检查',
|
||||||
@@ -158,6 +164,7 @@ const zh: Record<string, string> = {
|
|||||||
|
|
||||||
/* ── XML View ── */
|
/* ── XML View ── */
|
||||||
'xml.copyButton': '复制 XML',
|
'xml.copyButton': '复制 XML',
|
||||||
|
'xml.errorHeading': '无法生成 XML',
|
||||||
|
|
||||||
/* ── Monitor ── */
|
/* ── Monitor ── */
|
||||||
'monitor.disconnected': '未连接',
|
'monitor.disconnected': '未连接',
|
||||||
@@ -174,6 +181,8 @@ const zh: Record<string, string> = {
|
|||||||
'monitor.connect': '连接',
|
'monitor.connect': '连接',
|
||||||
'monitor.messages': '消息',
|
'monitor.messages': '消息',
|
||||||
'monitor.latency': '延迟',
|
'monitor.latency': '延迟',
|
||||||
|
'monitor.clearRuntime': '清空运行状态',
|
||||||
|
'monitor.closeMonitor': '关闭监视器',
|
||||||
'monitor.matched': '匹配',
|
'monitor.matched': '匹配',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
|
import { createNode } from './catalog'
|
||||||
import { syncActiveTree, switchDocumentTree } from './trees'
|
import { syncActiveTree, switchDocumentTree } from './trees'
|
||||||
import type { BehaviorTreeDocument } from '../types'
|
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument } from '../types'
|
||||||
|
|
||||||
export function serializeProject(document: BehaviorTreeDocument) {
|
export function serializeProject(document: BehaviorTreeDocument) {
|
||||||
return JSON.stringify(syncActiveTree(document), null, 2) + '\n'
|
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 {
|
export function parseProject(content: string): BehaviorTreeDocument {
|
||||||
const value: unknown = JSON.parse(content)
|
const value: unknown = JSON.parse(content)
|
||||||
if (!value || typeof value !== 'object') throw new Error('工程文件内容无效')
|
if (!value || typeof value !== 'object') throw new Error('工程文件内容无效')
|
||||||
@@ -16,6 +39,9 @@ export function parseProject(content: string): BehaviorTreeDocument {
|
|||||||
throw new Error('工程文件缺少节点或连接数据')
|
throw new Error('工程文件缺少节点或连接数据')
|
||||||
}
|
}
|
||||||
const document = candidate as BehaviorTreeDocument
|
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
|
const requestedTree = document.activeTreeId || document.mainTreeId
|
||||||
return document.trees?.length ? switchDocumentTree(document, requestedTree) : document
|
return document.trees?.length ? switchDocumentTree(document, requestedTree) : document
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,26 @@ function parseBehaviorTree(element: Element, models: Map<string, CustomNodeModel
|
|||||||
Array.from(nodeElement.children).forEach((child) => visit(child, node.id))
|
Array.from(nodeElement.children).forEach((child) => visit(child, node.id))
|
||||||
}
|
}
|
||||||
visit(treeRoot)
|
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 })
|
return layoutGraph({ id: treeId, nodes, edges })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,11 @@
|
|||||||
--color-root: #d99a20;
|
--color-root: #d99a20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Smooth theme transition ── */
|
||||||
|
*, *::before, *::after {
|
||||||
|
transition: background-color 0.35s ease, color 0.25s ease, border-color 0.35s ease, box-shadow 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||||
body { background: var(--bg-primary); }
|
body { background: var(--bg-primary); }
|
||||||
@@ -608,20 +613,24 @@ button:disabled { cursor: default; opacity: .38; }
|
|||||||
.node-top-row {
|
.node-top-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
.node-accent { align-self: stretch; flex: 0 0 4px; border-radius: 6px 0 0 6px; background: var(--category-color); }
|
.node-accent { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: 7px 0 0 7px; background: var(--category-color); }
|
||||||
.node-icon { display: grid; place-items: center; flex: 0 0 33px; height: 33px; margin-left: 10px; border: 1px solid color-mix(in srgb, var(--category-color) 35%, var(--border-icon-well)); border-radius: 6px; color: var(--category-color); background: var(--bg-icon-well); }
|
.node-icon { display: grid; place-items: center; flex: 0 0 33px; height: 33px; border: 1px solid color-mix(in srgb, var(--category-color) 35%, var(--border-icon-well)); border-radius: 6px; color: var(--category-color); background: var(--bg-icon-well); }
|
||||||
.node-copy { min-width: 0; margin: 0 11px 0 9px; flex: 1; }
|
.node-copy { min-width: 0; flex: 1; text-align: center; }
|
||||||
.node-title { max-width: 135px; overflow: hidden; color: var(--text-title); font-size: 12px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
.node-title { max-width: 135px; overflow: hidden; color: var(--text-title); font-size: 12px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; margin: 0 auto; }
|
||||||
.node-subtitle { max-width: 135px; overflow: hidden; color: var(--text-dim); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; margin-top: 1px; }
|
.node-subtitle { max-width: 135px; overflow: hidden; color: var(--text-dim); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; margin: 1px auto 0; }
|
||||||
.node-type { max-width: 132px; margin-top: 6px; overflow: hidden; color: var(--text-mono); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
.node-type { max-width: 132px; margin: 6px auto 0; overflow: hidden; color: var(--text-mono); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* ── Port params inside node card ── */
|
/* ── Port params inside node card ── */
|
||||||
.node-ports-row {
|
.node-ports-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 4px 10px 5px 10px;
|
padding: 4px 10px 5px 10px;
|
||||||
border-top: 1px solid var(--border-default);
|
border-top: 1px solid var(--border-default);
|
||||||
@@ -665,11 +674,9 @@ button:disabled { cursor: default; opacity: .38; }
|
|||||||
.node-ports-more {
|
.node-ports-more {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
padding-left: 2px;
|
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Handle labels ── */
|
|
||||||
.handle-wrapper {
|
.handle-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -677,19 +684,6 @@ button:disabled { cursor: default; opacity: .38; }
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
.handle-label {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
||||||
font-size: 7px;
|
|
||||||
font-weight: 650;
|
|
||||||
color: var(--text-dim);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.handle-label-in { top: 1px; }
|
|
||||||
.handle-label-out { bottom: 1px; }
|
|
||||||
|
|
||||||
.node-handle { width: 9px !important; height: 9px !important; border: 2px solid var(--handle-border) !important; background: var(--handle-bg) !important; }
|
.node-handle { width: 9px !important; height: 9px !important; border: 2px solid var(--handle-border) !important; background: var(--handle-bg) !important; }
|
||||||
.node-handle:hover { border-color: var(--category-color) !important; background: var(--category-color) !important; }
|
.node-handle:hover { border-color: var(--category-color) !important; background: var(--category-color) !important; }
|
||||||
.node-handle-target { top: -5px !important; }
|
.node-handle-target { top: -5px !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user