mirror of
https://gitee.com/dlmu-cone/Arborflow
synced 2026-10-24 02:02:19 +08:00
feat: dark/light theme, i18n (zh/en), inline node params with IN/OUT, root node, custom node creation, minimap & light mode contrast fixes
This commit is contained in:
@@ -1,26 +1,98 @@
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { CircleDot, OctagonAlert } from 'lucide-react'
|
||||
import { NODE_DEFINITION_MAP, canHaveChildren } from '../lib/catalog'
|
||||
import type { BehaviorNode } from '../types'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { BehaviorNode, PortDirection } from '../types'
|
||||
|
||||
const MAX_INLINE_PORTS = 5
|
||||
|
||||
function getPortDirection(
|
||||
portName: string,
|
||||
portDirections: Record<string, PortDirection> | undefined,
|
||||
): PortDirection | null {
|
||||
if (!portDirections) return null
|
||||
return portDirections[portName] || null
|
||||
}
|
||||
|
||||
export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
|
||||
const definition = NODE_DEFINITION_MAP.get(data.nodeType)
|
||||
const Icon = definition?.icon || CircleDot
|
||||
const status = data.runtimeStatus || 'IDLE'
|
||||
const { lang } = useI18n()
|
||||
|
||||
// Bilingual display: show Chinese as primary when zh, English as secondary
|
||||
const isZh = lang === 'zh'
|
||||
const primaryLabel = isZh ? (definition?.labelZh || data.label) : data.label
|
||||
const secondaryLabel = isZh ? data.label : (definition?.labelZh || '')
|
||||
|
||||
// Port entries for inline display
|
||||
const portEntries = Object.entries(data.ports || {})
|
||||
const visiblePorts = portEntries.slice(0, MAX_INLINE_PORTS)
|
||||
const hiddenCount = portEntries.length - MAX_INLINE_PORTS
|
||||
const isRoot = data.nodeType === 'Root'
|
||||
|
||||
return (
|
||||
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
|
||||
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
||||
{/* Top handle — skip for Root node since nothing connects above it */}
|
||||
{!isRoot && (
|
||||
<>
|
||||
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
|
||||
<span className="handle-label handle-label-in">IN</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="node-accent" />
|
||||
<div className="node-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-type">{data.registrationName}</div>
|
||||
|
||||
<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={primaryLabel}>{primaryLabel}</div>
|
||||
{secondaryLabel && secondaryLabel !== primaryLabel && (
|
||||
<div className="node-subtitle">{secondaryLabel}</div>
|
||||
)}
|
||||
{!secondaryLabel || secondaryLabel === primaryLabel ? (
|
||||
<div className="node-type">{data.registrationName}</div>
|
||||
) : (
|
||||
<div className="node-type">{data.registrationName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline port parameters */}
|
||||
{portEntries.length > 0 && (
|
||||
<div className="node-ports-row">
|
||||
{visiblePorts.map(([key, value]) => {
|
||||
const dir = getPortDirection(key, data.portDirections)
|
||||
return (
|
||||
<div className="node-port-param" key={key}>
|
||||
{dir && (
|
||||
<span className={`port-dir-badge dir-${dir === 'input' ? 'in' : 'out'}`}>
|
||||
{dir === 'input' ? 'IN' : 'OUT'}
|
||||
</span>
|
||||
)}
|
||||
<span className="port-key" title={key}>{key}</span>
|
||||
{value && <span className="port-value">: {value}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<div className="node-ports-more">...+{hiddenCount} more</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.breakpoint && <OctagonAlert className="breakpoint-icon" size={15} aria-label="Breakpoint" />}
|
||||
{status !== 'IDLE' && <span className={`runtime-badge runtime-${status.toLowerCase()}`}>{status}</span>}
|
||||
|
||||
{/* Root badge */}
|
||||
{isRoot && <span className="runtime-badge" style={{ color: 'var(--amber)', borderColor: 'var(--amber)', bottom: 'auto', top: 6, right: 7 }}>ROOT</span>}
|
||||
|
||||
{/* Bottom handle with OUT label — only if node can have children */}
|
||||
{canHaveChildren(data.nodeType) && (
|
||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||
<>
|
||||
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
|
||||
<span className="handle-label handle-label-out">OUT</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
|
||||
import { CATEGORY_LABELS } from '../lib/catalog'
|
||||
import type { BehaviorNode, BehaviorTreeDocument, ValidationIssue } from '../types'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
|
||||
|
||||
interface InspectorProps {
|
||||
document: BehaviorTreeDocument
|
||||
@@ -13,6 +13,8 @@ interface InspectorProps {
|
||||
}
|
||||
|
||||
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const updatePort = (oldKey: string, key: string, value: string) => {
|
||||
if (!selectedNode) return
|
||||
const ports = { ...selectedNode.data.ports }
|
||||
@@ -21,11 +23,27 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
|
||||
onUpdateNode(selectedNode.id, { ports })
|
||||
}
|
||||
|
||||
const updatePortDirection = (key: string, direction: PortDirection | '') => {
|
||||
if (!selectedNode) return
|
||||
const portDirections = { ...(selectedNode.data.portDirections || {}) }
|
||||
if (direction) {
|
||||
portDirections[key] = direction
|
||||
} else {
|
||||
delete portDirections[key]
|
||||
}
|
||||
onUpdateNode(selectedNode.id, { portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined })
|
||||
}
|
||||
|
||||
const removePort = (key: string) => {
|
||||
if (!selectedNode) return
|
||||
const ports = { ...selectedNode.data.ports }
|
||||
delete ports[key]
|
||||
onUpdateNode(selectedNode.id, { ports })
|
||||
const portDirections = selectedNode.data.portDirections ? { ...selectedNode.data.portDirections } : {}
|
||||
delete portDirections[key]
|
||||
onUpdateNode(selectedNode.id, {
|
||||
ports,
|
||||
portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const addPort = () => {
|
||||
@@ -36,88 +54,106 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
|
||||
onUpdateNode(selectedNode.id, { ports: { ...selectedNode.data.ports, [key]: '' } })
|
||||
}
|
||||
|
||||
const getPortDir = (key: string): PortDirection | '' => {
|
||||
if (!selectedNode?.data.portDirections) return ''
|
||||
return selectedNode.data.portDirections[key] || ''
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="right-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INSPECTOR</span>
|
||||
<h2>{selectedNode ? '节点属性' : '行为树'}</h2>
|
||||
<span className="eyebrow">{t('inspector.title')}</span>
|
||||
<h2>{selectedNode ? t('inspector.nodeProps') : t('inspector.treeProps')}</h2>
|
||||
</div>
|
||||
{selectedNode && <span className={`category-chip category-${selectedNode.data.category}`}>{CATEGORY_LABELS[selectedNode.data.category]}</span>}
|
||||
{selectedNode && (
|
||||
<span className={`category-chip category-${selectedNode.data.category}`}>
|
||||
{t(`cat.${selectedNode.data.category}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inspector-scroll">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div className="field-group">
|
||||
<label htmlFor="node-label">显示名称</label>
|
||||
<label htmlFor="node-label">{t('inspector.displayName')}</label>
|
||||
<input id="node-label" value={selectedNode.data.label} onChange={(event) => onUpdateNode(selectedNode.id, { label: event.target.value })} />
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor="registration-name">注册名称 / ID</label>
|
||||
<label htmlFor="registration-name">{t('inspector.registrationName')}</label>
|
||||
<input id="registration-name" value={selectedNode.data.registrationName} onChange={(event) => onUpdateNode(selectedNode.id, { registrationName: event.target.value })} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field-group">
|
||||
<label>节点类型</label>
|
||||
<label>{t('inspector.nodeType')}</label>
|
||||
<div className="read-only-value">{selectedNode.data.nodeType}</div>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label>运行匹配 ID</label>
|
||||
<label>{t('inspector.nodeId')}</label>
|
||||
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-divider" />
|
||||
<div className="section-title-row">
|
||||
<div className="section-title"><Braces size={15} />端口与黑板</div>
|
||||
<button className="icon-button compact" onClick={addPort} title="添加端口"><Plus size={15} /></button>
|
||||
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
|
||||
<button className="icon-button compact" onClick={addPort} title={t('inspector.addPort')}><Plus size={15} /></button>
|
||||
</div>
|
||||
<div className="ports-list">
|
||||
{Object.entries(selectedNode.data.ports).map(([key, value]) => (
|
||||
<div className="port-row" key={key}>
|
||||
<input className="port-key" value={key} onChange={(event) => updatePort(key, event.target.value, value)} aria-label="端口名" />
|
||||
<input value={value} onChange={(event) => updatePort(key, key, event.target.value)} placeholder="值或 {blackboard}" aria-label={`${key} 的值`} />
|
||||
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title="删除端口"><X size={14} /></button>
|
||||
<input className="port-key" value={key} onChange={(event) => updatePort(key, event.target.value, value)} aria-label={t('inspector.portKey')} />
|
||||
<select
|
||||
value={getPortDir(key)}
|
||||
onChange={(event) => updatePortDirection(key, event.target.value as PortDirection | '')}
|
||||
style={{ gridColumn: '1', gridRow: '2', height: 22, fontSize: 9, border: '1px solid var(--border-input)', borderRadius: 4, color: 'var(--text-primary)', background: 'var(--bg-input)' }}
|
||||
>
|
||||
<option value="">--</option>
|
||||
<option value="input">IN</option>
|
||||
<option value="output">OUT</option>
|
||||
</select>
|
||||
<input value={value} onChange={(event) => updatePort(key, key, event.target.value)} placeholder={t('inspector.portValue')} aria-label={`${key} ${t('inspector.portValue')}`} />
|
||||
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title={t('inspector.deleteNode')}><X size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">暂无端口</div>}
|
||||
{!Object.keys(selectedNode.data.ports).length && <div className="inline-empty">{t('inspector.noPorts')}</div>}
|
||||
</div>
|
||||
<div className="section-divider" />
|
||||
<label className="toggle-row">
|
||||
<span><span className="toggle-title">断点</span><span className="toggle-description">运行到此节点时标记</span></span>
|
||||
<span><span className="toggle-title">{t('inspector.breakpoint')}</span><span className="toggle-description">{t('inspector.breakpointDesc')}</span></span>
|
||||
<input type="checkbox" checked={selectedNode.data.breakpoint} onChange={(event) => onUpdateNode(selectedNode.id, { breakpoint: event.target.checked })} />
|
||||
</label>
|
||||
<div className="field-group notes-field">
|
||||
<label htmlFor="node-notes">备注</label>
|
||||
<label htmlFor="node-notes">{t('inspector.notes')}</label>
|
||||
<textarea id="node-notes" rows={4} value={selectedNode.data.notes} onChange={(event) => onUpdateNode(selectedNode.id, { notes: event.target.value })} />
|
||||
</div>
|
||||
<button className="danger-button" onClick={() => onDeleteNode(selectedNode.id)}><Trash2 size={15} />删除节点</button>
|
||||
<button className="danger-button" onClick={() => onDeleteNode(selectedNode.id)}><Trash2 size={15} />{t('inspector.deleteNode')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field-group">
|
||||
<label htmlFor="tree-title">工程名称</label>
|
||||
<label htmlFor="tree-title">{t('inspector.projectName')}</label>
|
||||
<input id="tree-title" value={document.title} onChange={(event) => onUpdateDocument({ title: event.target.value })} />
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor="tree-id">主树 ID</label>
|
||||
<label htmlFor="tree-id">{t('inspector.mainTreeId')}</label>
|
||||
<input id="tree-id" value={document.mainTreeId} onChange={(event) => onUpdateDocument({ mainTreeId: event.target.value })} />
|
||||
</div>
|
||||
<div className="tree-summary">
|
||||
<div><strong>{document.nodes.length}</strong><span>节点</span></div>
|
||||
<div><strong>{document.edges.length}</strong><span>连接</span></div>
|
||||
<div><strong>{issues.filter((item) => item.severity === 'error').length}</strong><span>错误</span></div>
|
||||
<div><strong>{document.nodes.length}</strong><span>{t('inspector.nodeCount')}</span></div>
|
||||
<div><strong>{document.edges.length}</strong><span>{t('inspector.edgeCount')}</span></div>
|
||||
<div><strong>{issues.filter((item) => item.severity === 'error').length}</strong><span>{t('inspector.errorCount')}</span></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="section-divider" />
|
||||
<div className="section-title-row">
|
||||
<div className="section-title"><AlertCircle size={15} />结构检查</div>
|
||||
<div className="section-title"><AlertCircle size={15} />{t('inspector.structureCheck')}</div>
|
||||
<span className="count-label">{issues.length}</span>
|
||||
</div>
|
||||
<div className="issue-list">
|
||||
{!issues.length && <div className="validation-ok">结构有效</div>}
|
||||
{!issues.length && <div className="validation-ok">{t('validation.valid')}</div>}
|
||||
{issues.map((issue, index) => (
|
||||
<button key={`${issue.message}-${index}`} className={`issue-row ${issue.severity}`} onClick={() => issue.nodeId && onSelectIssue(issue.nodeId)} disabled={!issue.nodeId}>
|
||||
<span className="issue-dot" />
|
||||
|
||||
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 } = useI18n()
|
||||
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
|
||||
|
||||
// Local working copy
|
||||
const [label, setLabel] = useState(node.data.label)
|
||||
const [registrationName, setRegistrationName] = useState(node.data.registrationName)
|
||||
const [ports, setPorts] = useState<Record<string, string>>({ ...node.data.ports })
|
||||
const [portDirections, setPortDirections] = useState<Record<string, PortDirection>>(
|
||||
node.data.portDirections ? { ...node.data.portDirections } : {},
|
||||
)
|
||||
const [breakpoint, setBreakpoint] = useState(node.data.breakpoint)
|
||||
const [notes, setNotes] = useState(node.data.notes)
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(node.id, {
|
||||
label,
|
||||
registrationName,
|
||||
ports,
|
||||
portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined,
|
||||
breakpoint,
|
||||
notes,
|
||||
})
|
||||
onClose()
|
||||
}, [node.id, label, registrationName, ports, portDirections, breakpoint, notes, onSave, onClose])
|
||||
|
||||
const addPort = useCallback(() => {
|
||||
let key = 'port'
|
||||
let idx = 1
|
||||
while (key in ports) key = `port_${idx++}`
|
||||
setPorts((prev) => ({ ...prev, [key]: '' }))
|
||||
}, [ports])
|
||||
|
||||
const updatePortKey = useCallback((oldKey: string, newKey: string) => {
|
||||
setPorts((prev) => {
|
||||
const next = { ...prev }
|
||||
const value = next[oldKey] || ''
|
||||
delete next[oldKey]
|
||||
next[newKey] = value
|
||||
return next
|
||||
})
|
||||
setPortDirections((prev) => {
|
||||
const next = { ...prev }
|
||||
if (oldKey in next) {
|
||||
next[newKey] = next[oldKey]
|
||||
delete next[oldKey]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updatePortValue = useCallback((key: string, value: string) => {
|
||||
setPorts((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
const updatePortDirection = useCallback((key: string, direction: PortDirection) => {
|
||||
setPortDirections((prev) => ({ ...prev, [key]: direction }))
|
||||
}, [])
|
||||
|
||||
const removePort = useCallback((key: string) => {
|
||||
setPorts((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPortDirections((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
const portEntries = Object.entries(ports)
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
<span style={{ color: 'var(--category-color)' }}>
|
||||
{definition?.labelZh && definition.labelZh !== definition.label ? definition.labelZh : definition?.label}
|
||||
</span>
|
||||
{t('modal.editNode')}
|
||||
</h2>
|
||||
<button className="bare-icon" onClick={onClose}><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Basic Info */}
|
||||
<div className="field-group">
|
||||
<label>{t('inspector.displayName')}</label>
|
||||
<input value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label>{t('inspector.registrationName')}</label>
|
||||
<input value={registrationName} onChange={(e) => setRegistrationName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field-group">
|
||||
<label>{t('inspector.nodeType')}</label>
|
||||
<div className="read-only-value">{node.data.nodeType}</div>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label>{t('inspector.nodeId')}</label>
|
||||
<div className="read-only-value mono" title={node.id}>{node.id.slice(0, 14)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* Ports */}
|
||||
<div className="section-title-row">
|
||||
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>
|
||||
<button className="icon-button compact" onClick={addPort} title={t('inspector.addPort')}><Plus size={15} /></button>
|
||||
</div>
|
||||
<div className="ports-list">
|
||||
{portEntries.map(([key, value]) => (
|
||||
<div className="port-row" key={key}>
|
||||
<input
|
||||
className="port-key"
|
||||
value={key}
|
||||
onChange={(e) => updatePortKey(key, e.target.value)}
|
||||
aria-label={t('inspector.portKey')}
|
||||
/>
|
||||
<select
|
||||
value={portDirections[key] || ''}
|
||||
onChange={(e) => updatePortDirection(key, e.target.value as PortDirection || 'input')}
|
||||
aria-label={t('inspector.portDirection')}
|
||||
>
|
||||
<option value="">--</option>
|
||||
<option value="input">IN</option>
|
||||
<option value="output">OUT</option>
|
||||
</select>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => updatePortValue(key, e.target.value)}
|
||||
placeholder={t('inspector.portValue')}
|
||||
aria-label={`${key} ${t('inspector.portValue')}`}
|
||||
/>
|
||||
<button className="bare-icon danger-hover" onClick={() => removePort(key)} title={t('inspector.deleteNode')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!portEntries.length && <div className="inline-empty">{t('inspector.noPorts')}</div>}
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* Breakpoint */}
|
||||
<label className="toggle-row">
|
||||
<span>
|
||||
<span className="toggle-title">{t('inspector.breakpoint')}</span>
|
||||
<span className="toggle-description">{t('inspector.breakpointDesc')}</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={breakpoint} onChange={(e) => setBreakpoint(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="field-group notes-field">
|
||||
<label>{t('inspector.notes')}</label>
|
||||
<textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="footer-left">
|
||||
<button className="danger-button" onClick={() => { onDelete(node.id); onClose() }}>
|
||||
<Trash2 size={15} />{t('modal.delete')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="footer-right">
|
||||
<button className="secondary-button" onClick={onClose}>{t('modal.cancel')}</button>
|
||||
<button className="primary-button" onClick={handleSave}>{t('modal.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,41 @@
|
||||
import { Search, SquareFunction, X, Zap } from 'lucide-react'
|
||||
import { Plus, Search, SquareFunction, TreePine, X, Zap } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog'
|
||||
import type { CustomNodeModel, NodeCategory, NodeType } from '../types'
|
||||
import { NODE_DEFINITIONS, NODE_DEFINITION_MAP } from '../lib/catalog'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { CustomNodeModel, NodeType } from '../types'
|
||||
|
||||
interface NodePaletteProps {
|
||||
customModels: CustomNodeModel[]
|
||||
onAddNode: (type: NodeType) => void
|
||||
onAddCustomNode: (modelId: string) => void
|
||||
onAddCustomModel: (model: CustomNodeModel) => void
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: NodeCategory[] = ['control', 'decorator', 'action', 'condition', 'subtree']
|
||||
const CATEGORY_ORDER = ['root', 'control', 'decorator', 'action', 'condition', 'subtree'] as const
|
||||
|
||||
export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePaletteProps) {
|
||||
export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCustomModel }: NodePaletteProps) {
|
||||
const { t, lang } = useI18n()
|
||||
const isZh = lang === 'zh'
|
||||
const [query, setQuery] = useState('')
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const groups = useMemo(() => CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: NODE_DEFINITIONS.filter((item) => item.category === category)
|
||||
.filter((item) => !normalizedQuery || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalizedQuery)),
|
||||
})).filter((group) => group.items.length), [normalizedQuery])
|
||||
const [creatingModel, setCreatingModel] = useState(false)
|
||||
const [newModelName, setNewModelName] = useState('')
|
||||
const [newModelType, setNewModelType] = useState<NodeType>('Action')
|
||||
|
||||
const groupedDefinitions = useMemo(() => {
|
||||
const defs = NODE_DEFINITIONS.filter((d) => {
|
||||
if (!normalizedQuery) return true
|
||||
const label = isZh ? d.labelZh : d.label
|
||||
const desc = isZh ? d.descriptionZh : d.description
|
||||
return `${label} ${desc} ${d.type}`.toLowerCase().includes(normalizedQuery)
|
||||
})
|
||||
const groups = new Map<string, typeof defs>()
|
||||
CATEGORY_ORDER.forEach((cat) => {
|
||||
const items = defs.filter((d) => d.category === cat)
|
||||
if (items.length) groups.set(cat, items)
|
||||
})
|
||||
return groups
|
||||
}, [normalizedQuery, isZh])
|
||||
|
||||
const visibleModels = useMemo(() => customModels.filter((model) => {
|
||||
if (!normalizedQuery) return true
|
||||
@@ -35,24 +52,61 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
const submitNewModel = () => {
|
||||
const trimmed = newModelName.trim()
|
||||
if (!trimmed) return
|
||||
const def = NODE_DEFINITION_MAP.get(newModelType)!
|
||||
const model: CustomNodeModel = {
|
||||
id: trimmed,
|
||||
nodeType: newModelType,
|
||||
category: def.category,
|
||||
ports: [],
|
||||
}
|
||||
if (def.defaultPorts) {
|
||||
model.ports = Object.entries(def.defaultPorts).map(([name, defaultValue]) => ({
|
||||
name,
|
||||
direction: 'input_port' as const,
|
||||
type: '',
|
||||
defaultValue,
|
||||
description: '',
|
||||
}))
|
||||
}
|
||||
onAddCustomModel(model)
|
||||
setNewModelName('')
|
||||
setNewModelType('Action')
|
||||
setCreatingModel(false)
|
||||
}
|
||||
|
||||
const catLabel = (cat: string) => {
|
||||
return t(`cat.${cat}`)
|
||||
}
|
||||
|
||||
const nodeLabel = (d: typeof NODE_DEFINITIONS[number]) => {
|
||||
return isZh ? d.labelZh : d.label
|
||||
}
|
||||
|
||||
const nodeDesc = (d: typeof NODE_DEFINITIONS[number]) => {
|
||||
return isZh ? d.descriptionZh : d.description
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="left-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">LIBRARY</span>
|
||||
<h2>节点</h2>
|
||||
<h2>{t('palette.title')}</h2>
|
||||
</div>
|
||||
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
|
||||
</div>
|
||||
<div className="palette-search">
|
||||
<Search size={16} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点" aria-label="搜索节点" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('palette.searchPlaceholder')} aria-label={t('palette.searchPlaceholder')} />
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
|
||||
</div>
|
||||
<div className="palette-scroll">
|
||||
{visibleModels.length > 0 && (
|
||||
<section className="palette-group custom-model-group">
|
||||
<h3>XML 自定义节点</h3>
|
||||
<h3>{t('palette.customNodes')}</h3>
|
||||
<div className="palette-items">
|
||||
{visibleModels.map((model) => {
|
||||
const Icon = model.category === 'condition' ? SquareFunction : Zap
|
||||
@@ -63,7 +117,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
|
||||
key={model.id}
|
||||
onDragStart={(event) => beginCustomDrag(event, model.id)}
|
||||
onClick={() => onAddCustomNode(model.id)}
|
||||
title={`${CATEGORY_LABELS[model.category]} · ${model.ports.length} 个端口`}
|
||||
title={`${catLabel(model.category)} · ${model.ports.length} 个端口`}
|
||||
>
|
||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||
<span className="palette-item-name">{model.id}</span>
|
||||
@@ -73,12 +127,42 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{groups.map(({ category, items }) => (
|
||||
{creatingModel ? (
|
||||
<div className="palette-custom-form">
|
||||
<div className="palette-custom-form-row">
|
||||
<label>{t('palette.customModelName')}</label>
|
||||
<input
|
||||
value={newModelName}
|
||||
onChange={(e) => setNewModelName(e.target.value)}
|
||||
placeholder="MyNode"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitNewModel(); if (e.key === 'Escape') setCreatingModel(false); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="palette-custom-form-row">
|
||||
<label>{t('palette.customModelType')}</label>
|
||||
<select value={newModelType} onChange={(e) => setNewModelType(e.target.value as NodeType)}>
|
||||
{NODE_DEFINITIONS.filter((d) => d.type !== 'Root').map((d) => (
|
||||
<option key={d.type} value={d.type}>{isZh ? d.labelZh : d.label} ({d.type})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="palette-custom-form-actions">
|
||||
<button className="primary-button compact" onClick={submitNewModel}>{t('palette.customModelCreate')}</button>
|
||||
<button className="secondary-button compact" onClick={() => setCreatingModel(false)}>{t('modal.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="palette-add-custom" onClick={() => setCreatingModel(true)}>
|
||||
<Plus size={14} /> {t('palette.customModelAdd')}
|
||||
</button>
|
||||
)}
|
||||
{Array.from(groupedDefinitions.entries()).map(([category, items]) => (
|
||||
<section className="palette-group" key={category}>
|
||||
<h3>{CATEGORY_LABELS[category]}</h3>
|
||||
<h3>{catLabel(category)}</h3>
|
||||
<div className="palette-items">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const Icon = item.icon || TreePine
|
||||
return (
|
||||
<button
|
||||
className={`palette-item category-${category}`}
|
||||
@@ -86,17 +170,17 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePa
|
||||
key={item.type}
|
||||
onDragStart={(event) => beginDrag(event, item.type)}
|
||||
onClick={() => onAddNode(item.type)}
|
||||
title={item.description}
|
||||
title={nodeDesc(item)}
|
||||
>
|
||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||
<span className="palette-item-name">{item.label}</span>
|
||||
<span className="palette-item-name">{nodeLabel(item)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groups.length && !visibleModels.length && <div className="panel-empty">没有匹配节点</div>}
|
||||
{!groupedDefinitions.size && !visibleModels.length && <div className="panel-empty">{t('palette.noMatch')}</div>}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user