From cd605877cc19711c2e5d75a59a2580c4250d99ab Mon Sep 17 00:00:00 2001 From: TuxMonkey Date: Wed, 5 Aug 2026 00:57:31 +0800 Subject: [PATCH] feat: subtree navigation, i18n cleanup, UI polish (centered node text, smooth theme transition, remapped icons) --- src/App.tsx | 125 +++++++++++++++++++--------- src/components/BehaviorNodeCard.tsx | 12 +-- src/components/Inspector.tsx | 10 ++- src/components/MonitorPanel.tsx | 35 ++++---- src/components/NodeDetailModal.tsx | 4 +- src/components/NodePalette.tsx | 8 +- src/i18n/en.ts | 9 ++ src/i18n/zh.ts | 9 ++ src/lib/project.ts | 28 ++++++- src/lib/xml.ts | 20 +++++ src/styles.css | 36 ++++---- 11 files changed, 200 insertions(+), 96 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 02dd73e..559434f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,7 +33,7 @@ import { Sun, TreePine, Undo2, - Upload, + FileUp, } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BehaviorNodeCard } from './components/BehaviorNodeCard' @@ -94,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]) @@ -158,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]) @@ -196,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, @@ -211,10 +231,21 @@ function Editor() { }, [commit]) const updateNode = useCallback((nodeId: string, data: Partial) => { - commit((current) => ({ - ...current, - nodes: current.nodes.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node), - })) + commit((current) => { + const node = current.nodes.find((n) => n.id === nodeId) + 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]) const onNodesChange = useCallback((changes: NodeChange[]) => { @@ -228,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) => ({ @@ -252,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]) @@ -263,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]) @@ -276,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({ @@ -290,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]) @@ -305,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]) @@ -321,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]) @@ -350,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(() => { @@ -449,19 +480,19 @@ function Editor() {
ArborFlowBehavior Tree Studio
-
{treeDocument.title}{dirty && }
+
{treeDocument.title}{dirty && }
- - - + + + - - + + - - + + - +
@@ -493,22 +524,22 @@ function Editor() { {treeMenuOpen && (
-
行为树 · {treeGraphs.length}
+
{t('tree.heading')} · {treeGraphs.length}
{treeGraphs.map((tree) => ( ))}
)} -
- +
+
- +
@@ -524,7 +555,20 @@ function Editor() { onNodesChange={onNodesChange} onConnect={connectNodes} 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) }} onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }} onNodeDragStart={() => { dragStartRef.current = treeDocument }} @@ -568,10 +612,10 @@ function Editor() {
{treeDocument.mainTreeId}.xml
- +
{xmlPreview.error ? ( -
无法生成 XML{xmlPreview.error}
+
{t('xml.errorHeading')}{xmlPreview.error}
) : (
{xmlPreview.value}
)} @@ -594,12 +638,13 @@ function Editor() { onUpdateNode={updateNode} onDeleteNode={deleteNode} onSelectIssue={selectAndRevealNode} + onNavigateToTree={navigateToTree} />
-
{errors ? `${errors} 个结构错误` : <>结构有效}
-
{treeGraphs.length} 棵树{treeDocument.nodes.length} 节点{treeDocument.edges.length} 连接{zoom}%{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}
+
{errors ? `${errors} ${t('status.errors')}` : <>{t('status.valid')}}
+
{treeGraphs.length} {t('status.trees')}{treeDocument.nodes.length} {t('status.nodes')}{treeDocument.edges.length} {t('status.edges')}{zoom}%{window.arborflow ? `Desktop · ${window.arborflow.platform}` : t('status.webPreview')}
{toast &&
{toast.message}
} diff --git a/src/components/BehaviorNodeCard.tsx b/src/components/BehaviorNodeCard.tsx index 9facdbd..86d241c 100644 --- a/src/components/BehaviorNodeCard.tsx +++ b/src/components/BehaviorNodeCard.tsx @@ -35,10 +35,7 @@ export function BehaviorNodeCard({ data, selected }: NodeProps) {
{/* Top handle — skip for Root node since nothing connects above it */} {!isRoot && ( - <> - - IN - + )}
@@ -87,12 +84,9 @@ export function BehaviorNodeCard({ data, selected }: NodeProps) { {/* Root badge */} {isRoot && ROOT} - {/* Bottom handle with OUT label — only if node can have children */} + {/* Bottom handle — only if node can have children */} {canHaveChildren(data.nodeType) && ( - <> - - OUT - + )}
) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 32a30bc..4997b77 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react' +import { AlertCircle, Braces, ExternalLink, Plus, Trash2, X } from 'lucide-react' import { useI18n } from '../context/I18nContext' import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types' @@ -10,9 +10,10 @@ interface InspectorProps { onUpdateNode: (nodeId: string, data: Partial) => void onDeleteNode: (nodeId: string) => void onSelectIssue: (nodeId: string) => void + onNavigateToTree: (treeId: string) => void } -export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) { +export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue, onNavigateToTree }: InspectorProps) { const { t } = useI18n() const updatePort = (oldKey: string, key: string, value: string) => { @@ -94,6 +95,11 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
{selectedNode.id}
+ {selectedNode.data.nodeType === 'SubTree' && selectedNode.data.registrationName && ( + + )}
{t('inspector.ports')}
diff --git a/src/components/MonitorPanel.tsx b/src/components/MonitorPanel.tsx index f82972a..afbef2c 100644 --- a/src/components/MonitorPanel.tsx +++ b/src/components/MonitorPanel.tsx @@ -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 = { - disconnected: '未连接', connecting: '连接中', connected: '已连接', error: '连接错误', -} - export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearStatuses }: MonitorPanelProps) { + const { t, lang } = useI18n() + const STATE_LABEL: Record = { + 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('disconnected') @@ -120,20 +121,20 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
Real-time Monitor{STATE_LABEL[connectionState]}
- - + +
- +
setEndpoint(event.target.value)} disabled={shouldConnect} /> {shouldConnect ? ( - + ) : ( - + )}
@@ -142,29 +143,29 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta setRosTopic(event.target.value)} disabled={shouldConnect} />
-
消息{messageCount}
-
延迟{lastLatency === null ? '--' : `${lastLatency} ms`}
-
匹配{events.filter((event) => event.matchedNodeIds.length).length}
+
{t('monitor.messages')}{messageCount}
+
{t('monitor.latency')}{lastLatency === null ? '--' : `${lastLatency} ms`}
+
{t('monitor.matched')}{events.filter((event) => event.matchedNodeIds.length).length}
{(['RUNNING', 'SUCCESS', 'FAILURE', 'IDLE', 'SKIPPED'] as RuntimeStatus[]).map((status) => {status})}
-
事件流{connectionState === 'connecting' && }
+
{t('monitor.eventStream')}{connectionState === 'connecting' && }
- {!events.length &&
等待节点状态
} + {!events.length &&
{t('monitor.waiting')}
} {events.map((event) => (
- + {event.nodeRef} {event.status} - {!event.matchedNodeIds.length && 未匹配} + {!event.matchedNodeIds.length && {t('monitor.unmatched')}}
))}
diff --git a/src/components/NodeDetailModal.tsx b/src/components/NodeDetailModal.tsx index 9fdb0b6..b28741d 100644 --- a/src/components/NodeDetailModal.tsx +++ b/src/components/NodeDetailModal.tsx @@ -12,7 +12,7 @@ interface NodeDetailModalProps { } export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) { - const { t } = useI18n() + const { t, lang } = useI18n() const definition = NODE_DEFINITION_MAP.get(node.data.nodeType) // Local working copy @@ -100,7 +100,7 @@ export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailM

- {definition?.labelZh && definition.labelZh !== definition.label ? definition.labelZh : definition?.label} + {lang === 'zh' && definition?.labelZh ? definition.labelZh : definition?.label} {t('modal.editNode')}

diff --git a/src/components/NodePalette.tsx b/src/components/NodePalette.tsx index 7d33ea3..06e6213 100644 --- a/src/components/NodePalette.tsx +++ b/src/components/NodePalette.tsx @@ -93,7 +93,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus