添加反向树加载功能,重构节点和树的选择逻辑

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-17 19:30:19 +08:00
parent 65d99bb7a8
commit 1de4f9db8d
15 changed files with 611 additions and 139 deletions

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@antv/g6": "^5.0.49",
"@ant-design/icons-vue": "^7.0.1",
"@antv/x6": "^3.1.2",
"@antv/x6-vue-shape": "^3.0.2",
"ant-design-vue": "^4.2.6",

View File

@@ -26,7 +26,7 @@ export function resolveConnectionRelation(graph: Graph): PlatformRelation[] {
// 过滤无效/临时边
const validEdges = edges.filter(edge => {
// 过滤临时边X6 拖拽连线时生成的未完成边)
const isTempEdge = edge?.attr('line/stroke') === 'transparent' || edge.id.includes('temp');
const isTempEdge = edge?.attr('line/stroke') === 'transparent' || String(edge.id).includes('temp');
if (isTempEdge) {
tempEdgeIds.add(edge.id);
return false;

View File

@@ -10,9 +10,54 @@
import { HttpRequestClient } from '@/utils/request';
import type { NodeTemplatesResponse } from './template';
import type { BehaviorTree, BehaviorTreeDetailsResponse, BehaviorTreePageResponse, BehaviorTreeRequest } from './tree';
import type { BasicResponse } from '@/types';
import type { BasicResponse, PageableResponse } from '@/types';
import type { PlatformListableResponse } from '../types';
export interface TreeTemplateRow {
id: number;
type: string;
name: string;
multiable?: boolean;
}
export interface TreeNodeInstanceRow {
id: number;
treeId: number;
templateId: number;
instanceName: string;
isRoot: number;
}
export interface TreeConnectionRow {
id: number;
treeId: number;
parentNodeId: number;
childNodeId: number;
orderIndex: number;
}
export interface TreeTemplateParameterDefRow {
id: number;
templateId: number;
paramKey: string;
dataType: string;
defaultValue: string;
description: string;
templateType: string;
}
export interface TreeNodeParameterRow {
id: number;
treeId: number;
nodeInstanceId: number;
paramDefId: number;
value: string;
groupIndex: number;
}
const req = HttpRequestClient.create<BasicResponse>({
baseURL: '/api',
});
@@ -20,6 +65,20 @@ const req = HttpRequestClient.create<BasicResponse>({
export const findNodeTemplates = (): Promise<NodeTemplatesResponse> => {
return req.get('/system/nodetemplate/all');
};
export const findTemplateParameterDefs = (query={pageSize: 1000, pageNum: 1}): Promise<PageableResponse<TreeTemplateParameterDefRow>> => {
return req.get('/system/templateparameterdef/list', query);
}
export const findNodeConnections = (query: {treeId: number}): Promise<PageableResponse<TreeConnectionRow>> => {
return req.get('/system/nodeconnection/list', {pageSize: 1000, pageNum: 1, ...query});
};
export const findNodeParameters = (query: {treeId: number}): Promise<PageableResponse<TreeNodeParameterRow>> => {
return req.get('/system/nodeparameter/list', {pageSize: 1000, pageNum: 1, ...query});
}
export const findTreeNodeInstances = (query: {treeId: number}): Promise<PageableResponse<TreeNodeInstanceRow>> => {
return req.get('/system/treenodeinstance/list', {pageSize: 1000, pageNum: 1, ...query});
}
export const findTreesByQuery = (query: Partial<BehaviorTreeRequest> = {}): Promise<BehaviorTreePageResponse> => {
return req.get<BehaviorTreePageResponse>('/system/behaviortree/list', query);

View File

@@ -7,11 +7,13 @@
<div class="ks-model-builder-left">
<ScenariosCard
ref="scenariosCardRef"
:scenarioId="currentScenarioId"
@select-scenario="handleSelectScenario"
/>
<TressCard
ref="treesCardRef"
:scenarioId="currentScenarioId"
:platformId="currentPlatformId"
@create-tree="handleCreateTree"
@select-tree="handleSelectTree"
/>
@@ -76,6 +78,7 @@ import Properties from './properties.vue';
import type { NodeDragTemplate } from './template';
import type { BehaviorTree } from './tree';
import { createGraphTaskElementFromTemplate } from './utils';
import { resolveBehaviorTreeGraph } from './reverse-tree/tree-graph-resolver';
import { createGraphTaskElement, createLineOptions, type GraphContainer, type GraphTaskElement, hasElements, hasRootElementNode, resolveGraph, useGraphCanvas } from '../graph';
import { registerNodeElement } from './register';
@@ -268,102 +271,6 @@ export default defineComponent({
}
};
const handleSelectTree = (tree: BehaviorTree) => {
destroyGraph();
currentPlatformId.value = null;
console.info('handleSelectTree', tree);
findOneTreeById(tree.id).then(r => {
if (r.data) {
let nodeGraph: GraphContainer | null = null;
try {
nodeGraph = JSON.parse(r.data?.xmlContent as unknown as string) as unknown as GraphContainer;
} catch (e: any) {
console.error('parse error,cause:', e);
}
if (!nodeGraph) {
nodeGraph = {
nodes: [],
edges: [],
};
}
currentBehaviorTree.value = {
...r.data,
graph: nodeGraph,
};
currentTreeEditing.value = true;
// 加载下属平台
loadSubPlatforms(r.data.platformId);
nextTick(() => {
initGraph();
});
} else {
message.error(r.msg ?? '行为树不存在.');
}
});
};
const createElements = () => {
clearGraph();
nextTick(() => {
if (currentBehaviorTree.value?.graph && graph.value) {
if (currentBehaviorTree.value?.graph.nodes) {
currentBehaviorTree.value?.graph.nodes.forEach(ele => {
const node = createGraphTaskElement(ele as GraphTaskElement);
console.info('create node: ', ele);
// 将节点添加到画布
graph.value?.addNode(node as Node);
});
fitToScreen();
}
// 然后添加所有边,确保包含桩点信息
currentBehaviorTree.value?.graph.edges.forEach(edgeData => {
graph.value?.addEdge({
...edgeData,
...createLineOptions(),
});
});
}
});
};
const STORAGE_KEY_SCENARIO = 'designer_from_scenario_id';
const resolveQuery = ()=> {
console.log(currentRoute);
let scenarioId = Number(currentRoute.query.scenario);
if (!isNaN(scenarioId) && scenarioId > 0) {
currentScenarioId.value = scenarioId;
fromScenarioPage.value = true;
sessionStorage.setItem(STORAGE_KEY_SCENARIO, String(scenarioId));
} else {
// 尝试从 sessionStorage 恢复(页面刷新或 SPA 内部跳转后 query 参数丢失的情况)
const stored = sessionStorage.getItem(STORAGE_KEY_SCENARIO);
const storedId = Number(stored);
if (stored && !isNaN(storedId) && storedId > 0) {
currentScenarioId.value = storedId;
fromScenarioPage.value = true;
} else {
fromScenarioPage.value = false;
}
}
let platformId = Number(currentRoute.query.platform);
if (!isNaN(platformId)) {
currentPlatformId.value = platformId;
} else {
currentPlatformId.value = null;
}
}
// 处理选择场景
const handleSelectScenario = (scenario: any) => {
currentScenarioId.value = scenario.id;
};
const initGraphConfig = (_graph?: GraphContainer) => {
const graph: GraphContainer = _graph ? _graph : {
nodes: [],
@@ -384,6 +291,96 @@ export default defineComponent({
};
};
const createElements = () => {
clearGraph();
nextTick(() => {
if (currentBehaviorTree.value?.graph && graph.value) {
if (currentBehaviorTree.value?.graph.nodes) {
currentBehaviorTree.value?.graph.nodes.forEach(ele => {
const node = createGraphTaskElement(ele as GraphTaskElement);
// 将节点添加到画布
graph.value?.addNode(node as Node);
});
}
setTimeout(() => {
// 然后添加所有边,确保包含桩点信息
currentBehaviorTree.value?.graph.edges.forEach(edgeData => {
graph.value?.addEdge({
...edgeData,
...createLineOptions(),
});
});
});
fitToScreen();
console.info('create elements: ', currentBehaviorTree.value?.graph);
}
});
};
const applyBehaviorTree = (tree: BehaviorTree) => {
destroyGraph();
currentPlatformId.value = tree.platformId ?? null;
currentBehaviorTree.value = tree;
currentTreeEditing.value = true;
selectedModelNode.value = null;
selectedNodeTaskElement.value = null;
loadSubPlatforms(currentPlatformId.value);
console.log('currentBehaviorTree.value: ', tree);
nextTick(() => {
initGraph();
console.log('initGraphTree: ', tree);
});
};
const handleSelectTree = (tree: BehaviorTree) => {
console.info('handleSelectTree', tree);
findOneTreeById(tree.id).then(r => {
if (r.data) {
resolveBehaviorTreeGraph(r.data.id, r.data.xmlContent).then(nodeGraph => {
applyBehaviorTree({
...r.data,
graph: nodeGraph,
});
}).catch(error => {
console.error('resolve tree graph error:', error);
message.error('加载行为树图失败');
});
} else {
message.error(r.msg ?? '行为树不存在.');
}
});
};
const resolveQuery = ()=> {
console.log(currentRoute);
if (!currentRoute.query.scenario) {
return
}
let scenarioId = Number(currentRoute.query.scenario);
if (!isNaN(scenarioId)) {
currentScenarioId.value = scenarioId;
fromScenarioPage.value = true;
} else {
fromScenarioPage.value = false;
}
let platformId = Number(currentRoute.query.platform);
if (!isNaN(platformId)) {
currentPlatformId.value = platformId;
} else {
currentPlatformId.value = null;
}
}
// 处理选择场景
const handleSelectScenario = (scenario: any) => {
currentScenarioId.value = scenario.id;
};
const handleCreateTree = () => {
destroyGraph();
initGraphConfig();
@@ -407,9 +404,17 @@ export default defineComponent({
try {
graph.value = createCanvas(canvas.value);
console.log('画布初始化成功');
if (!currentBehaviorTree.value) {
initGraphConfig();
}
createElements();
graph.value?.on('edge:click', (args: any) => {
const edge = args.edge;
console.info('点击了连线:', args);
// 这里可以添加选中连线的逻辑,比如显示属性面板等
});
// 监听缩放变化
handleGraphEvent('scale', ({ sx }: { sx: number }) => {
currentZoom.value = sx;
@@ -451,7 +456,6 @@ export default defineComponent({
const init = () => {
console.info('init');
nextTick(() => {
initGraph();
window.addEventListener('resize', handleResize);
@@ -461,8 +465,6 @@ export default defineComponent({
if (currentPlatformId.value) {
findOneTreeByPlatformId(currentPlatformId.value).then(r => {
console.log(r);
if (r.data) {
handleSelectTree(r.data);
} else {
@@ -488,7 +490,6 @@ export default defineComponent({
};
const handleGoback = ()=> {
sessionStorage.removeItem(STORAGE_KEY_SCENARIO);
window.location.href = `/app/decision/communication?scenario=${currentScenarioId.value}`
}
@@ -542,6 +543,7 @@ export default defineComponent({
return {
nodeCommands,
currentScenarioId,
currentPlatformId,
platforms,
subPlatforms,
scenariosCardRef,

View File

@@ -18,7 +18,7 @@
</a-space>
</template>
<div class="port port-in" data-port="in-0" magnet="passive">
<div class="port port-in" data-port="in-0" port="in-0" magnet="passive">
<div class="triangle-left"></div>
</div>
<div class="w-full ks-designer-node-text">
@@ -31,7 +31,7 @@
</p>
</a-tooltip>
</div>
<div class="port port-out" data-port="out-0" magnet="active">
<div class="port port-out" data-port="out-0" port="out-0" magnet="active">
<div class="triangle-right" ></div>
</div>
</a-card>

View File

@@ -72,8 +72,8 @@
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import type { NodeDragTemplate, NodeTemplate } from './template';
import { findNodeTemplates } from './api';
import { safePreventDefault, safeStopPropagation } from '@/utils/event';
import { loadNodeTemplatesOnce } from './template-metadata-loader';
export default defineComponent({
emits: ['drag-item-start', 'drag-item-end'],
@@ -95,7 +95,7 @@ export default defineComponent({
conditionTemplates.value = [];
actionsTemplates.value = [];
findNodeTemplates().then(r => {
loadNodeTemplatesOnce().then(r => {
templateData.value = r.data;
if (r.data) {
r.data.forEach(tpl => {

View File

@@ -113,7 +113,7 @@
</a-select>
<a-select :placeholder="`请选择${setting.description}`"
allow-clear
v-else-if="setting.paramKey === 'command'" v-model:value="setting.defaultValue">
v-else-if="isNodeCommandParameter(setting.paramKey as string | null | undefined)" v-model:value="setting.defaultValue">
<a-select-option v-for="pl in nodeCommands" :value="pl.command">{{pl.chineseName}}</a-select-option>
</a-select>
<a-input v-else v-model:value="setting.defaultValue"
@@ -148,7 +148,7 @@
</a-select>
<a-select :placeholder="`请选择${setting.description}`"
allow-clear
v-else-if="setting.paramKey === 'command'" v-model:value="setting.defaultValue">
v-else-if="isNodeCommandParameter(setting.paramKey as string | null | undefined)" v-model:value="setting.defaultValue">
<a-select-option v-for="pl in nodeCommands" :value="pl.command">{{pl.chineseName}}</a-select-option>
</a-select>
<a-input v-else v-model:value="setting.defaultValue" :placeholder="setting.description" size="small" />
@@ -209,6 +209,10 @@ export default defineComponent({
},
emits: ['update-element', 'update-tree'],
setup(props, { emit }) {
const isNodeCommandParameter = (paramKey: string | null | undefined): boolean => {
return ['command', 'should_task'].includes(paramKey ?? '');
};
const platforms = ref<Platform[]>(props.platforms ?? []);
const subPlatforms = ref<Platform[]>(props.subPlatforms ?? []);
const nodeCommands = ref<NodeCommand[]>(props.nodeCommands ?? []);
@@ -448,6 +452,7 @@ export default defineComponent({
groupedParameters,
getPlatformTabName,
getAvailablePlatforms,
isNodeCommandParameter,
actionSpaceColumns,
activeTopTabsKey,
activeBottomTabsKey,

View File

@@ -0,0 +1,49 @@
import type { GraphContainer } from '../../graph';
import { findNodeConnections, findNodeParameters, findTreeNodeInstances } from '../api';
import type { TreeTemplateRow } from '../api';
import { buildGraphFromReverseTreeRows } from './reverse-tree';
import type { ReverseTreeRows } from './reverse-tree';
import { loadNodeTemplatesOnce, loadTemplateParameterDefsOnce } from '../template-metadata-loader';
// 已反演过的树直接返回缓存图,无需重复请求
const graphCache = new Map<number, GraphContainer>();
export const loadReverseTreeGraph = async (treeId: number): Promise<GraphContainer | null> => {
const cached = graphCache.get(treeId);
if (cached) {
return cached;
}
// 模板类接口只请求一次,树级接口每次切树请求
const [nodeTemplatesResponse, templateParameterDefs, instancesResponse, connectionsResponse] =
await Promise.all([
loadNodeTemplatesOnce(),
loadTemplateParameterDefsOnce(),
findTreeNodeInstances({ treeId }),
findNodeConnections({ treeId }),
]);
const parametersResponse = await findNodeParameters({ treeId });
const rows: ReverseTreeRows = {
templates: (nodeTemplatesResponse.data ?? []).map(t => ({
id: t.id,
type: t.type ?? 'action',
name: t.name ?? '',
multiable: t.multiable,
})) as TreeTemplateRow[],
templateParameterDefs: templateParameterDefs,
nodeInstances: instancesResponse.rows ?? [],
connections: connectionsResponse.rows ?? [],
nodeParameters: parametersResponse.rows ?? [],
};
const graph = buildGraphFromReverseTreeRows(treeId, rows);
if (graph) {
graphCache.set(treeId, graph);
}
return graph;
};
export const invalidateReverseTreeCache = (treeId: number): void => {
graphCache.delete(treeId);
};

View File

@@ -0,0 +1,267 @@
import type { ElementParameter, GraphContainer, GraphEdgeElement, GraphTaskElement } from '../../graph';
import type {
TreeConnectionRow as ReverseTreeConnectionRow,
TreeNodeInstanceRow as ReverseTreeNodeInstanceRow,
TreeNodeParameterRow as ReverseTreeNodeParameterRow,
TreeTemplateParameterDefRow as ReverseTreeTemplateParameterDefRow,
TreeTemplateRow as ReverseTreeTemplateRow,
} from '../api';
export interface ReverseTreeRows {
templates: ReverseTreeTemplateRow[];
nodeInstances: ReverseTreeNodeInstanceRow[];
connections: ReverseTreeConnectionRow[];
templateParameterDefs: ReverseTreeTemplateParameterDefRow[];
nodeParameters: ReverseTreeNodeParameterRow[];
}
const NODE_WIDTH = 250;
const CONTROL_HEIGHT = 60;
const ACTION_HEIGHT = 120;
const ROOT_X = 80;
const ROOT_Y = 50;
const LEVEL_Y_SPACING = 120;
const SIBLING_X_SPACING = 280;
const createNodeKey = (treeId: number, nodeId: number): string => {
return `reverse-tree-${treeId}-node-${nodeId}`;
};
const createEdgeKey = (treeId: number, edgeId: number): string => {
return `reverse-tree-${treeId}-edge-${edgeId}`;
};
const createBehaviorTreeEdgeEndpoints = (sourceCell: string, targetCell: string) => {
return {
source: {
cell: sourceCell,
port: 'out-0',
},
target: {
cell: targetCell,
port: 'in-0',
},
};
};
const resolveGroup = (type: string): 'control' | 'condition' | 'action' => {
if (type === 'root' || type === 'parallel' || type === 'select' || type === 'sequence') {
return 'control';
}
if (type === 'condition') {
return 'condition';
}
return 'action';
};
const resolveTemplateType = (type: string): string => {
if (type === 'select') {
return 'selector';
}
return type;
};
const resolveNodeHeight = (group: 'control' | 'condition' | 'action'): number => {
return group === 'action' ? ACTION_HEIGHT : CONTROL_HEIGHT;
};
const findRootNodeId = (
instances: ReverseTreeNodeInstanceRow[],
connections: ReverseTreeConnectionRow[],
): number | null => {
const rootNode = instances.find(item => item.isRoot === 1);
if (rootNode) {
return rootNode.id;
}
const childIds = new Set(connections.map(item => item.childNodeId));
const topNode = instances.find(item => !childIds.has(item.id));
return topNode?.id ?? null;
};
export const buildGraphFromReverseTreeRows = (
treeId: number,
rows: ReverseTreeRows,
): GraphContainer | null => {
const templates = rows.templates;
const templateParameterDefs = rows.templateParameterDefs;
const nodeParameters = rows.nodeParameters;
const instances = rows.nodeInstances.filter(item => item.treeId === treeId);
const connections = rows.connections
.filter(item => item.treeId === treeId)
.sort((left, right) => left.orderIndex - right.orderIndex);
const treeNodeParameters = nodeParameters.filter(item => item.treeId === treeId);
if (instances.length === 0) {
return null;
}
const templateMap = new Map(templates.map(item => [item.id, item]));
const templateParameterDefsMap = new Map<number, ReverseTreeTemplateParameterDefRow[]>();
const instanceMap = new Map(instances.map(item => [item.id, item]));
const childrenMap = new Map<number, ReverseTreeConnectionRow[]>();
const nodeParametersMap = new Map<number, ReverseTreeNodeParameterRow[]>();
templateParameterDefs.forEach(definition => {
const currentDefinitions = templateParameterDefsMap.get(definition.templateId) ?? [];
currentDefinitions.push(definition);
currentDefinitions.sort((left, right) => left.id - right.id);
templateParameterDefsMap.set(definition.templateId, currentDefinitions);
});
treeNodeParameters.forEach(parameter => {
const currentParameters = nodeParametersMap.get(parameter.nodeInstanceId) ?? [];
currentParameters.push(parameter);
currentParameters.sort((left, right) => {
if (left.groupIndex !== right.groupIndex) {
return left.groupIndex - right.groupIndex;
}
return left.id - right.id;
});
nodeParametersMap.set(parameter.nodeInstanceId, currentParameters);
});
connections.forEach(connection => {
const siblingConnections = childrenMap.get(connection.parentNodeId) ?? [];
siblingConnections.push(connection);
siblingConnections.sort((left, right) => left.orderIndex - right.orderIndex);
childrenMap.set(connection.parentNodeId, siblingConnections);
});
const rootNodeId = findRootNodeId(instances, connections);
if (!rootNodeId) {
return null;
}
const leafCountCache = new Map<number, number>();
const countLeaves = (nodeId: number): number => {
const cachedCount = leafCountCache.get(nodeId);
if (cachedCount) {
return cachedCount;
}
const children = childrenMap.get(nodeId) ?? [];
if (children.length === 0) {
leafCountCache.set(nodeId, 1);
return 1;
}
const totalLeaves = children.reduce((total, connection) => {
return total + countLeaves(connection.childNodeId);
}, 0);
leafCountCache.set(nodeId, totalLeaves);
return totalLeaves;
};
const nodes: GraphTaskElement[] = [];
const edges: GraphEdgeElement[] = [];
const buildNodeParameters = (instance: ReverseTreeNodeInstanceRow): ElementParameter[] => {
const parameterDefinitions = templateParameterDefsMap.get(instance.templateId) ?? [];
if (parameterDefinitions.length === 0) {
return [];
}
const parameterRows = nodeParametersMap.get(instance.id) ?? [];
const groupIndexes = parameterRows.length > 0
? Array.from(new Set(parameterRows.map(item => item.groupIndex))).sort((left, right) => left - right)
: [0];
return groupIndexes.flatMap(groupIndex => {
return parameterDefinitions.map(definition => {
const parameterRow = parameterRows.find(item => item.paramDefId === definition.id && item.groupIndex === groupIndex);
return {
id: definition.id,
templateId: definition.templateId,
paramKey: definition.paramKey,
dataType: definition.dataType,
defaultValue: parameterRow?.value ?? definition.defaultValue,
description: definition.description,
templateType: definition.templateType,
groupIndex,
} as ElementParameter;
});
});
};
const walk = (nodeId: number, startX: number, y: number): number => {
const instance = instanceMap.get(nodeId);
if (!instance) {
return startX;
}
const template = templateMap.get(instance.templateId);
const templateType = template?.type ?? 'action';
const group = resolveGroup(templateType);
const parameters = buildNodeParameters(instance);
const leafCount = countLeaves(nodeId);
const totalWidth = leafCount * SIBLING_X_SPACING;
const centerX = startX + totalWidth / 2 - SIBLING_X_SPACING / 2;
const currentNode: GraphTaskElement = {
id: instance.id,
key: createNodeKey(treeId, instance.id),
type: 'task',
template: instance.templateId,
templateType: resolveTemplateType(templateType),
name: instance.instanceName || template?.name || `节点 ${instance.id}`,
category: group,
group,
description: instance.instanceName || template?.name || `节点 ${instance.id}`,
multiable: template?.multiable === true,
order: 0,
position: {
x: Math.round(centerX),
y: Math.round(y),
},
width: NODE_WIDTH,
height: resolveNodeHeight(group),
inputs: null,
outputs: null,
parameters,
variables: [],
};
nodes.push(currentNode);
const children = childrenMap.get(nodeId) ?? [];
let childStartX = startX;
children.forEach(connection => {
const childLeafCount = countLeaves(connection.childNodeId);
walk(connection.childNodeId, childStartX, y + LEVEL_Y_SPACING + currentNode.height);
const childInstance = instanceMap.get(connection.childNodeId);
const edge: GraphEdgeElement = {
id: connection.id,
key: createEdgeKey(treeId, connection.id),
...createBehaviorTreeEdgeEndpoints(
currentNode.key as string,
createNodeKey(treeId, connection.childNodeId),
),
attrs: {},
router: {},
connector: null,
};
edges.push(edge);
const currentEdges = ((currentNode as GraphTaskElement & { edges?: GraphEdgeElement[] }).edges ?? []).slice();
currentEdges.push({
...edge,
sourceName: currentNode.name,
targetName: childInstance?.instanceName ?? childInstance?.id ?? connection.childNodeId,
});
(currentNode as GraphTaskElement & { edges?: GraphEdgeElement[] }).edges = currentEdges;
childStartX += childLeafCount * SIBLING_X_SPACING;
});
return centerX;
};
walk(rootNodeId, ROOT_X, ROOT_Y);
return {
nodes,
edges,
};
};

View File

@@ -0,0 +1,43 @@
import type { GraphContainer } from '../../graph';
import { loadReverseTreeGraph } from './reverse-tree-loader';
const EMPTY_GRAPH: GraphContainer = {
nodes: [],
edges: [],
};
export const parseBehaviorTreeGraph = (xmlContent: string | null | undefined): GraphContainer | null => {
if (!xmlContent) {
return null;
}
try {
return JSON.parse(xmlContent) as GraphContainer;
} catch (error) {
console.error('parse behavior tree graph error:', error);
return null;
}
};
export const hasBehaviorTreeNodes = (graph: GraphContainer | null | undefined): boolean => {
return Boolean(graph?.nodes && graph.nodes.length > 0);
};
export const loadBehaviorTreeGraphById = async (treeId: number): Promise<GraphContainer> => {
return (await loadReverseTreeGraph(treeId)) ?? { ...EMPTY_GRAPH };
};
export const resolveBehaviorTreeGraph = async (
treeId: number,
xmlContent: string | null | undefined,
): Promise<GraphContainer> => {
// void xmlContent;
// 如需恢复动态选择逻辑,可切回下面这段:
const savedGraph = parseBehaviorTreeGraph(xmlContent);
if (hasBehaviorTreeNodes(savedGraph)) {
return savedGraph as GraphContainer;
}
return loadBehaviorTreeGraphById(treeId);
};

View File

@@ -14,7 +14,7 @@
</div>
<a-list :data-source="scenarios || []" size="small" style="min-height: 25vh">
<template #renderItem="{ item }">
<a-list-item :class="{ 'ks-item-selected': selectedId === item.id }" @click="()=> handleSelect(item)">
<a-list-item :class="{ 'ks-item-selected': scenarioId === item.id }" @click="()=> handleSelect(item)">
<a-flex>
<a-tooltip placement="bottom">
<template #title>
@@ -38,13 +38,16 @@
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import { defineComponent, onMounted, ref, watch, type PropType } from 'vue';
import { CopyOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
import type { Scenario, ScenarioRequest } from '../communication/types';
import { findScenarioByQuery } from '../communication/api';
import { substring } from '@/utils/strings';
export default defineComponent({
props: {
scenarioId: Number as PropType<number>,
},
emits: ['select-scenario'],
components: {
CopyOutlined,
@@ -60,7 +63,6 @@ export default defineComponent({
});
const activeKey = ref<number>(0);
const totalScenarios = ref<number>(0);
const selectedId = ref<number | null>(null);
const loadScenarios = (cb?: () => void) => {
findScenarioByQuery(scenarioQuery.value).then(r => {
@@ -77,7 +79,6 @@ export default defineComponent({
};
const handleSelect = (record: Scenario) => {
selectedId.value = record.id;
emit('select-scenario', record);
};
@@ -85,9 +86,13 @@ export default defineComponent({
onMounted(() => {
loadScenarios(() => {
if(scenarios.value.length > 0){
selectedId.value = scenarios.value[0]!.id;
emit('select-scenario', scenarios.value[0]);
if (_props.scenarioId) {
return;
}
// 默认选中第一个场景
const selectedScenario = scenarios.value[0];
if (selectedScenario) {
emit('select-scenario', selectedScenario);
}
});
});
@@ -97,7 +102,6 @@ export default defineComponent({
totalScenarios,
substring,
activeKey,
selectedId,
scenarios,
scenarioQuery,
loadScenarios,

View File

@@ -0,0 +1,32 @@
import { findNodeTemplates, findTemplateParameterDefs } from './api';
import type { TreeTemplateParameterDefRow } from './api';
import type { NodeTemplatesResponse } from './template';
// 模板列表:全局只请求一次
let nodeTemplatesPromise: Promise<NodeTemplatesResponse> | null = null;
// 模板参数定义:全局只请求一次
let templateParameterDefsPromise: Promise<TreeTemplateParameterDefRow[]> | null = null;
export const loadNodeTemplatesOnce = (): Promise<NodeTemplatesResponse> => {
if (!nodeTemplatesPromise) {
nodeTemplatesPromise = findNodeTemplates().catch(err => {
// 请求失败时清除缓存,下次可以重试
nodeTemplatesPromise = null;
return Promise.reject(err);
});
}
return nodeTemplatesPromise;
};
export const loadTemplateParameterDefsOnce = (): Promise<TreeTemplateParameterDefRow[]> => {
if (!templateParameterDefsPromise) {
templateParameterDefsPromise = findTemplateParameterDefs()
.then(r => r.rows ?? [])
.catch(err => {
templateParameterDefsPromise = null;
return Promise.reject(err);
});
}
return templateParameterDefsPromise;
};

View File

@@ -18,7 +18,7 @@
</div>
<a-list :data-source="behaviorTrees || []" size="small" style="min-height: 25vh">
<template #renderItem="{ item }">
<a-list-item :class="{ 'ks-item-selected': selectedId === item.id }" @click="()=> handleSelect(item)">
<a-list-item :class="{ 'ks-item-selected': platformId === item.id || selectedId === item.id }" @click="()=> handleSelect(item)">
<a-flex>
<a-tooltip placement="bottom">
<template #title>
@@ -58,7 +58,7 @@
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent, ref, watch } from 'vue';
import { defineComponent, onMounted, ref, watch } from 'vue';
import { CheckOutlined, CopyOutlined, DeleteOutlined, EditFilled, PlusOutlined } from '@ant-design/icons-vue';
import type { BehaviorTree, BehaviorTreeRequest } from './tree';
import { copyTree, deleteOneTreeById, findTreesByQuery } from './api';
@@ -66,7 +66,8 @@ import { substring } from '@/utils/strings';
export default defineComponent({
props: {
scenarioId:Number as PropType<number>,
scenarioId: Number as PropType<number | undefined>,
platformId: Number as PropType<number | null>,
},
emits: ['select-tree', 'create-tree'],
components: {
@@ -76,38 +77,48 @@ export default defineComponent({
DeleteOutlined,
EditFilled,
},
setup(_props, { emit }) {
setup(props, { emit }) {
const behaviorTrees = ref<BehaviorTree[]>([]);
const behaviorTreeQuery = ref<Partial<BehaviorTreeRequest>>({
name: null,
pageNum: 1,
pageSize: 8,
scenarioId: _props.scenarioId,
scenarioId: props.scenarioId,
});
const activeKey = ref<number>(1);
const totalTress = ref<number>(0);
const selectedId = ref<number | null>(null);
watch(
() => _props.scenarioId,
() => props.scenarioId,
() => {
selectedId.value = null;
if (!_props.scenarioId) {
if (!props.scenarioId) {
behaviorTrees.value = [];
totalTress.value = 0;
return;
}
behaviorTreeQuery.value.pageNum = 1;
behaviorTreeQuery.value.scenarioId = _props.scenarioId;
loadTress();
behaviorTreeQuery.value.scenarioId = props.scenarioId;
loadTress(() => {
if (props.platformId) {
return;
}
// 默认选中第一个行为树
const selectedTree = behaviorTrees.value[0];
if (selectedTree) {
selectedId.value = selectedTree.id;
emit('select-tree', selectedTree);
}
});
},
{ immediate: true }
);
function loadTress(){
function loadTress(cb?: () => void) {
findTreesByQuery(behaviorTreeQuery.value).then(r => {
behaviorTrees.value = r.rows;
totalTress.value = r.total ?? 0;
if (cb) cb();
});
};

View File

@@ -100,8 +100,8 @@ export interface ModelElement extends GraphBaseElement {
export interface GraphEdgeElement {
id: number;
key: NullableString;
source: NullableString;
target: NullableString;
source: NullableString | Record<string, any>;
target: NullableString | Record<string, any>;
attrs: Record<any, any>;
router: Record<any, any>;
connector: any;

View File

@@ -58,14 +58,12 @@ export const useGraphCanvas = (readonly: boolean = false): UseGraphCanvas => {
if (graph.value) {
clearGraph();
// 等待 Vue 完成卸载
nextTick(() => {
graph.value?.dispose(); // 销毁 Graph 实例
// 同步销毁 Graph 实例,避免异步导致的竞态条件
graph.value.dispose();
graph.value = null;
if (container.value) {
container.value.innerHTML = ''; // 清空容器内容
}
});
} else if (container.value) {
container.value.innerHTML = '';
}
@@ -222,7 +220,8 @@ export const useGraphCanvas = (readonly: boolean = false): UseGraphCanvas => {
// 监听节点选中事件
graph.value.on('node:selected', ({ node }) => {
console.info('node select', node);
console.info('node select', node,node.getData());
emitGraphEvent('node:selected', node);
});