@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('画布初始化成功');
|
||||
initGraphConfig();
|
||||
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;
|
||||
@@ -450,7 +455,6 @@ export default defineComponent({
|
||||
|
||||
const init = () => {
|
||||
console.info('init');
|
||||
|
||||
|
||||
nextTick(() => {
|
||||
initGraph();
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
267
modeler/src/views/decision/designer/reverse-tree/reverse-tree.ts
Normal file
267
modeler/src/views/decision/designer/reverse-tree/reverse-tree.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,14 +58,12 @@ export const useGraphCanvas = (readonly: boolean = false): UseGraphCanvas => {
|
||||
if (graph.value) {
|
||||
clearGraph();
|
||||
|
||||
// 等待 Vue 完成卸载
|
||||
nextTick(() => {
|
||||
graph.value?.dispose(); // 销毁 Graph 实例
|
||||
graph.value = null;
|
||||
if (container.value) {
|
||||
container.value.innerHTML = ''; // 清空容器内容
|
||||
}
|
||||
});
|
||||
// 同步销毁 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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user