决策树

This commit is contained in:
MHW
2026-05-07 16:56:16 +08:00
parent 7a16b0643e
commit c9125c8902
12 changed files with 1443 additions and 149 deletions

View File

@@ -0,0 +1,415 @@
<template>
<div class="rule-decision-tree">
<div class="rule-decision-tree__toolbar">
<a-popover placement="bottomLeft" trigger="click" overlay-class-name="rule-decision-tree-snapshot-popover">
<template #content>
<pre class="rule-decision-tree__popover-json">{{ previewJson }}</pre>
</template>
<a-button type="link" size="small" class="rule-decision-tree__snapshot-btn">
全局参数快照{{ previewEntryCount }}
</a-button>
</a-popover>
</div>
<div v-if="errorMsg" class="rule-decision-tree__banner rule-decision-tree__banner--error">
{{ errorMsg }}
</div>
<div v-else-if="emptyHint" class="rule-decision-tree__banner">
{{ emptyHint }}
</div>
<div v-else-if="payload" class="rule-decision-tree__grid">
<section
v-for="block in payload.blocks"
:key="block.blockId"
class="rule-decision-tree__pane"
>
<header class="rule-decision-tree__pane-header">
<div class="rule-decision-tree__pane-title">{{ block.title || block.blockId }}</div>
<div class="rule-decision-tree__pane-meta">
模块{{ block.moduleCode || '-' }} / salience{{ block.salience ?? '-' }}
</div>
</header>
<div
:ref="(el) => registerHost(block.blockId, el)"
class="rule-decision-tree__canvas"
/>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { findRuleDecisionTree } from './api';
import type { RuleConfigRequest, RuleDecisionBlock, RuleDecisionNode, RuleDecisionTreePayload } from './types';
const props = defineProps<{
query: RuleConfigRequest,
refreshKey: number,
}>();
const payload = ref<RuleDecisionTreePayload | null>(null);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const hostMap = new Map<string, HTMLDivElement>();
const graphMap = new Map<string, Graph>();
const resizeMap = new Map<string, ResizeObserver>();
const renderVersion = ref(0);
const previewEntryCount = computed(() => {
const p = payload.value?.globalParamsPreview;
return p ? Object.keys(p).length : 0;
});
const previewJson = computed(() => {
const p = payload.value?.globalParamsPreview;
if (!p) {
return '';
}
try {
return JSON.stringify(p, null, 2);
} catch {
return String(p);
}
});
const BLOCK_COLORS: Record<string, string> = {
equipment: '#5B8FF9',
target: '#F6903D',
position: '#61DDAA',
track: '#9270CA',
};
const NODE_COLORS: Record<string, string> = {
root: '#5B8FF9',
input_group: '#5B8FF9',
input_param: '#4fb3ff',
step_group: '#61DDAA',
step: '#49c59c',
rule_group: '#F6903D',
rule: '#ff9c4a',
condition: '#ffd166',
task_types: '#9ad0f5',
param_group: '#3db2ff',
param: '#5ab9ff',
output_group: '#f08c6c',
output: '#ffb366',
outcome_group: '#9270CA',
outcome: '#a98cf0',
end: '#e8684a',
empty: '#6f8194',
};
const registerHost = (blockId: string, el: unknown) => {
if (!(el instanceof HTMLDivElement)) {
disposeBlock(blockId);
hostMap.delete(blockId);
return;
}
hostMap.set(blockId, el);
};
const flattenTree = (block: RuleDecisionBlock) => {
const nodes: Array<{ id: string; label: string; nodeType: string; valueText?: string | null; description?: string | null }> = [];
const edges: Array<{ source: string; target: string }> = [];
const rootId = `block:${block.blockId}:root`;
nodes.push({
id: rootId,
label: block.title || block.blockId,
nodeType: 'root',
valueText: block.salience != null ? `salience=${block.salience}` : undefined,
description: block.droolsRuleName || undefined,
});
const visit = (node: RuleDecisionNode, parentId: string) => {
const desc = node.description ? (node.valueText ? `${node.description} | ${node.valueText}` : node.description) : (node.valueText ?? '');
nodes.push({
id: node.key,
label: node.title,
nodeType: node.nodeType,
description: desc || undefined,
});
edges.push({ source: parentId, target: node.key });
(node.children ?? []).forEach((child) => visit(child, node.key));
};
(block.nodes ?? []).forEach((node) => visit(node, rootId));
return { nodes, edges };
};
const disposeBlock = (blockId: string) => {
resizeMap.get(blockId)?.disconnect();
resizeMap.delete(blockId);
const graph = graphMap.get(blockId);
if (graph) {
try {
graph.destroy();
} catch {
/* ignore */
}
graphMap.delete(blockId);
}
const host = hostMap.get(blockId);
if (host) {
host.replaceChildren();
}
};
const renderBlock = async (block: RuleDecisionBlock, version: number) => {
const host = hostMap.get(block.blockId);
if (!host) {
return;
}
disposeBlock(block.blockId);
if (version !== renderVersion.value) {
return;
}
const width = Math.max(host.clientWidth, 320);
const height = Math.max(host.clientHeight, 240);
const accent = BLOCK_COLORS[block.blockId] ?? '#5B8FF9';
const data = flattenTree(block);
const graph = new Graph({
container: host,
width,
height,
data: {
nodes: data.nodes.map((node) => ({
id: node.id,
data: node,
})),
edges: data.edges,
},
layout: {
type: 'antv-dagre',
rankdir: 'TB',
nodesep: 20,
ranksep: 42,
},
node: {
type: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'input_group') return 'rect';
if (t === 'end') return 'diamond';
return 'circle';
},
style: {
size: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'root') return 18;
if (t === 'input_group' || t === 'end') return 20;
return 14;
},
fill: (model: { data?: { nodeType?: string } }) => NODE_COLORS[model.data?.nodeType ?? ''] ?? accent,
stroke: '#0d1f2c',
lineWidth: 1.5,
labelText: (model: { data?: { label?: string; description?: string } }) => {
const label = model.data?.label ?? '';
const description = model.data?.description;
return description ? `${label}\n${description}` : label;
},
labelFill: '#e8f4f8',
labelFontSize: 11,
labelLineHeight: 15,
labelWordWrap: true,
labelMaxWidth: 160,
},
},
edge: {
style: {
stroke: 'rgba(120, 170, 200, 0.52)',
lineWidth: 1.2,
endArrow: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas'],
});
await graph.render();
if (version !== renderVersion.value) {
try {
graph.destroy();
} catch {
/* ignore */
}
return;
}
await graph.fitView({ when: 'always' });
graphMap.set(block.blockId, graph);
const observer = new ResizeObserver(() => {
const nextWidth = Math.max(host.clientWidth, 320);
const nextHeight = Math.max(host.clientHeight, 240);
graph.setSize(nextWidth, nextHeight);
void graph.fitView({ when: 'overflow' });
});
observer.observe(host);
resizeMap.set(block.blockId, observer);
};
const renderAll = async () => {
if (!payload.value?.blocks?.length) {
return;
}
await nextTick();
const version = renderVersion.value;
for (const block of payload.value.blocks) {
await renderBlock(block, version);
}
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
payload.value = null;
renderVersion.value += 1;
Array.from(graphMap.keys()).forEach(disposeBlock);
try {
const r = await findRuleDecisionTree({
pageNum: props.query.pageNum,
pageSize: props.query.pageSize,
ruleCode: props.query.ruleCode ?? undefined,
ruleName: props.query.ruleName ?? undefined,
levelCode: props.query.levelCode ?? undefined,
kindCode: props.query.kindCode ?? undefined,
moduleCode: props.query.moduleCode ?? undefined,
enabled: props.query.enabled ?? undefined,
});
if (r.code !== 200 || !r.data) {
errorMsg.value = r.msg ?? '加载决策树失败';
return;
}
if (!r.data.blocks?.length) {
emptyHint.value = '当前没有可展示的规则决策树数据';
return;
}
payload.value = r.data;
await renderAll();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `决策树请求失败:${msg}`;
message.error(errorMsg.value);
}
};
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
void load();
},
{ immediate: true },
);
onBeforeUnmount(() => {
renderVersion.value += 1;
Array.from(graphMap.keys()).forEach(disposeBlock);
});
</script>
<style scoped lang="less">
.rule-decision-tree {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
}
.rule-decision-tree__toolbar {
flex: 0 0 auto;
padding: 0 2px;
}
.rule-decision-tree__snapshot-btn {
padding-left: 0;
font-size: 11px;
color: #8fafbd;
}
.rule-decision-tree__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.rule-decision-tree__banner--error {
color: #ff9c9c;
}
.rule-decision-tree__grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rule-decision-tree__pane {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid rgba(80, 120, 150, 0.25);
background: linear-gradient(180deg, rgba(8, 20, 30, 0.78) 0%, rgba(5, 16, 29, 0.9) 100%);
overflow: hidden;
}
.rule-decision-tree__pane-header {
flex: 0 0 auto;
padding: 8px 10px 6px;
border-bottom: 1px solid rgba(80, 120, 150, 0.2);
background: rgba(10, 28, 40, 0.78);
}
.rule-decision-tree__pane-title {
font-size: 14px;
color: #d8edf6;
}
.rule-decision-tree__pane-meta {
margin-top: 2px;
font-size: 11px;
color: #7f9aaa;
}
.rule-decision-tree__canvas {
flex: 1;
min-height: 0;
width: 100%;
background: transparent;
}
</style>
<style lang="less">
.rule-decision-tree-snapshot-popover {
.ant-popover-inner-content {
max-width: min(520px, 90vw);
max-height: 70vh;
overflow: auto;
background: #081e3b;
}
.ant-popover-inner {
background: #081e3b;
}
}
.rule-decision-tree__popover-json {
margin: 0;
font-size: 10px;
line-height: 1.35;
color: #c9dfe8;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,466 @@
<template>
<div class="rule-decision-tree-simple">
<div class="rule-decision-tree-simple__toolbar">
<a-popover placement="bottomLeft" trigger="click" overlay-class-name="rule-decision-tree-simple-snapshot-popover">
<template #content>
<pre class="rule-decision-tree-simple__popover-json">{{ previewJson }}</pre>
</template>
<a-button type="link" size="small" class="rule-decision-tree-simple__snapshot-btn">
全局参数快照{{ previewEntryCount }}
</a-button>
</a-popover>
</div>
<div v-if="errorMsg" class="rule-decision-tree-simple__banner rule-decision-tree-simple__banner--error">
{{ errorMsg }}
</div>
<div v-else-if="emptyHint" class="rule-decision-tree-simple__banner">
{{ emptyHint }}
</div>
<div v-else ref="hostRef" class="rule-decision-tree-simple__canvas" />
</div>
</template>
<script setup lang="ts">
import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { findRuleDecisionTree } from './api';
import type { RuleConfigRequest, RuleDecisionBlock, RuleDecisionNode, RuleDecisionTreePayload } from './types';
const props = defineProps<{
query: RuleConfigRequest,
refreshKey: number,
}>();
type GraphNode = {
id: string,
label: string,
nodeType: string,
description?: string,
fullText?: string,
};
const hostRef = ref<HTMLDivElement | null>(null);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const payload = ref<RuleDecisionTreePayload | null>(null);
const renderVersion = ref(0);
let graph: Graph | null = null;
let resizeObserver: ResizeObserver | null = null;
const previewEntryCount = computed(() => {
const p = payload.value?.globalParamsPreview;
return p ? Object.keys(p).length : 0;
});
const previewJson = computed(() => {
const p = payload.value?.globalParamsPreview;
if (!p) {
return '';
}
try {
return JSON.stringify(p, null, 2);
} catch {
return String(p);
}
});
const NODE_COLORS: Record<string, string> = {
start: '#4fb3ff',
block: '#5B8FF9',
params: '#49c59c',
rules: '#ff9c4a',
outputs: '#a98cf0',
end: '#e8684a',
};
const BLOCK_TITLE: Record<string, string> = {
equipment: '装备规则',
target: '目标规则',
position: '阵位规则',
track: '航迹规则',
};
const flatten = (nodes: RuleDecisionNode[] | null | undefined): RuleDecisionNode[] => {
const out: RuleDecisionNode[] = [];
const visit = (items: RuleDecisionNode[] | null | undefined) => {
(items ?? []).forEach((item) => {
out.push(item);
visit(item.children ?? []);
});
};
visit(nodes);
return out;
};
const scoreParamNode = (node: RuleDecisionNode) => {
const text = `${node.title ?? ''} ${node.description ?? ''} ${node.valueText ?? ''}`.toLowerCase();
let score = 0;
const keywords = [
'模式', '类型', '目标', '装备', '阵位', '航迹', '选择', '分配', '距离', '阈值', '权重',
'比例', '数量', '规则', 'mode', 'type', 'target', 'equipment', 'position', 'track',
'select', 'assign', 'distance', 'threshold', 'weight', 'ratio', 'count', 'limit', 'min', 'max',
];
keywords.forEach((keyword) => {
if (text.includes(keyword)) {
score += 2;
}
});
if (text.includes('生效值')) {
score += 1;
}
return score;
};
const pickImportantParams = (flatNodes: RuleDecisionNode[]) => {
return flatNodes
.filter((node) => node.nodeType === 'input_param' || node.nodeType === 'param')
.sort((a, b) => scoreParamNode(b) - scoreParamNode(a))
.slice(0, 4);
};
const pickCoreRules = (flatNodes: RuleDecisionNode[]) => {
return flatNodes
.filter((node) => node.nodeType === 'rule')
.slice(0, 2);
};
const pickOutputs = (flatNodes: RuleDecisionNode[]) => {
const list = flatNodes.filter((node) => node.nodeType === 'output' || node.nodeType === 'outcome');
return list.slice(0, 3);
};
const buildMergedTree = (tree: RuleDecisionTreePayload) => {
const nodes: GraphNode[] = [];
const edges: Array<{ source: string; target: string }> = [];
const startId = 'simple:start';
nodes.push({
id: startId,
label: '输入参数',
nodeType: 'start',
description: `汇总四类规则块共享的运行时参数(${Object.keys(tree.globalParamsPreview ?? {}).length}项)`,
});
let previousMainId = startId;
const blocks = [...(tree.blocks ?? [])];
for (const block of blocks) {
const mainId = `simple:block:${block.blockId}`;
nodes.push({
id: mainId,
label: BLOCK_TITLE[block.blockId] ?? (block.title || block.blockId),
nodeType: 'block',
description: `salience=${block.salience ?? '-'};保留核心规则与关键参数`,
});
edges.push({ source: previousMainId, target: mainId });
const flatNodes = flatten(block.nodes);
const importantParams = pickImportantParams(flatNodes);
const coreRules = pickCoreRules(flatNodes);
const outputs = pickOutputs(flatNodes);
const paramId = `simple:block:${block.blockId}:params`;
nodes.push({
id: paramId,
label: '关键参数',
nodeType: 'params',
description: importantParams.length > 0
? importantParams.map((item) => `${item.title}${item.valueText ? `${item.valueText}` : ''}`).join('')
: '无关键参数',
fullText: importantParams.length > 0
? `关键参数:${importantParams.map((item) => `${item.title}${item.valueText ? `=${item.valueText}` : ''}`).join('')}`
: '关键参数:无',
});
edges.push({ source: mainId, target: paramId });
const ruleId = `simple:block:${block.blockId}:rules`;
nodes.push({
id: ruleId,
label: '核心规则',
nodeType: 'rules',
description: coreRules.length > 0
? coreRules.map((item) => item.title).join('')
: '无核心规则',
fullText: coreRules.length > 0
? `核心规则:${coreRules.map((item) => item.title).join('')}`
: '核心规则:无',
});
edges.push({ source: mainId, target: ruleId });
const outputId = `simple:block:${block.blockId}:outputs`;
nodes.push({
id: outputId,
label: '阶段输出',
nodeType: 'outputs',
description: outputs.length > 0
? outputs.map((item) => String(item.title || item.description || '')).join('')
: '无阶段输出',
fullText: outputs.length > 0
? `阶段输出:${outputs.map((item) => String(item.title || item.description || '')).join('')}`
: '阶段输出:无',
});
edges.push({ source: mainId, target: outputId });
previousMainId = mainId;
}
nodes.push({
id: 'simple:end',
label: '输出 / 结束',
nodeType: 'end',
description: '四类规则块汇总后输出最终结果',
fullText: '输出 / 结束:四类规则块汇总后输出最终结果',
});
edges.push({ source: previousMainId, target: 'simple:end' });
return { nodes, edges };
};
const disposeGraph = () => {
resizeObserver?.disconnect();
resizeObserver = null;
if (graph) {
try {
graph.destroy();
} catch {
/* ignore */
}
graph = null;
}
hostRef.value?.replaceChildren();
};
const renderGraph = async (tree: RuleDecisionTreePayload, version: number) => {
const host = hostRef.value;
if (!host) {
return;
}
disposeGraph();
if (version !== renderVersion.value) {
return;
}
const data = buildMergedTree(tree);
graph = new Graph({
container: host,
width: Math.max(host.clientWidth, 320),
height: Math.max(host.clientHeight, 240),
data: {
nodes: data.nodes.map((node) => ({
id: node.id,
data: node,
})),
edges: data.edges,
},
layout: {
type: 'antv-dagre',
rankdir: 'LR',
nodesep: 28,
ranksep: 56,
},
node: {
type: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'start') return 'rect';
if (t === 'end') return 'diamond';
return 'circle';
},
style: {
size: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'start' || t === 'end') return 26;
if (t === 'block') return 20;
if (t === 'params' || t === 'rules' || t === 'outputs') return 16;
return 16;
},
fill: (model: { data?: { nodeType?: string } }) => NODE_COLORS[model.data?.nodeType ?? 'block'] ?? '#5B8FF9',
stroke: '#0d1f2c',
lineWidth: 1.5,
labelText: (model: { data?: { label?: string; description?: string; fullText?: string; nodeType?: string } }) => {
const text = model.data?.fullText;
if (text) {
return text;
}
const label = model.data?.label ?? '';
const description = model.data?.description;
return description ? `${label}${description}` : label;
},
labelFill: '#e8f4f8',
labelFontSize: 11,
labelLineHeight: 15,
labelWordWrap: true,
labelMaxWidth: 360,
labelPlacement: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'params' || t === 'rules' || t === 'outputs') {
return 'right';
}
return 'bottom';
},
labelOffsetX: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'params' || t === 'rules' || t === 'outputs') {
return 14;
}
return 0;
},
labelTextAlign: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'params' || t === 'rules' || t === 'outputs') {
return 'left';
}
return 'center';
},
labelBackground: true,
labelBackgroundFill: 'rgba(6, 21, 40, 0.88)',
labelBackgroundRadius: 4,
labelPadding: [6, 8],
},
},
edge: {
style: {
stroke: 'rgba(120, 170, 200, 0.52)',
lineWidth: 1.2,
endArrow: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas'],
});
await graph.render();
if (version !== renderVersion.value) {
disposeGraph();
return;
}
await graph.fitView({ when: 'always' });
resizeObserver = new ResizeObserver(() => {
if (!graph || !hostRef.value) {
return;
}
graph.setSize(Math.max(hostRef.value.clientWidth, 320), Math.max(hostRef.value.clientHeight, 240));
void graph.fitView({ when: 'overflow' });
});
resizeObserver.observe(host);
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
payload.value = null;
renderVersion.value += 1;
disposeGraph();
try {
const r = await findRuleDecisionTree({
pageNum: props.query.pageNum,
pageSize: props.query.pageSize,
ruleCode: props.query.ruleCode ?? undefined,
ruleName: props.query.ruleName ?? undefined,
levelCode: props.query.levelCode ?? undefined,
kindCode: props.query.kindCode ?? undefined,
moduleCode: props.query.moduleCode ?? undefined,
enabled: props.query.enabled ?? undefined,
});
if (r.code !== 200 || !r.data) {
errorMsg.value = r.msg ?? '加载简化决策树失败';
return;
}
if (!r.data.blocks?.length) {
emptyHint.value = '当前没有可展示的简化决策树数据';
return;
}
payload.value = r.data;
await nextTick();
await renderGraph(r.data, renderVersion.value);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `简化决策树请求失败:${msg}`;
message.error(errorMsg.value);
}
};
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
void load();
},
{ immediate: true },
);
onBeforeUnmount(() => {
renderVersion.value += 1;
disposeGraph();
});
</script>
<style scoped lang="less">
.rule-decision-tree-simple {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
}
.rule-decision-tree-simple__toolbar {
flex: 0 0 auto;
padding: 0 2px;
}
.rule-decision-tree-simple__snapshot-btn {
padding-left: 0;
font-size: 11px;
color: #8fafbd;
}
.rule-decision-tree-simple__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.rule-decision-tree-simple__banner--error {
color: #ff9c9c;
}
.rule-decision-tree-simple__canvas {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid rgba(80, 120, 150, 0.22);
border-radius: 4px;
background: rgba(4, 18, 28, 0.35);
}
</style>
<style lang="less">
.rule-decision-tree-simple-snapshot-popover {
.ant-popover-inner-content {
max-width: min(520px, 90vw);
max-height: 70vh;
overflow: auto;
background: #081e3b;
}
.ant-popover-inner {
background: #081e3b;
}
}
.rule-decision-tree-simple__popover-json {
margin: 0;
font-size: 10px;
line-height: 1.35;
color: #c9dfe8;
white-space: pre-wrap;
}
</style>

View File

@@ -12,20 +12,28 @@
class="rule-knowledge-graph"
:class="{
'rule-knowledge-graph--four-blocks': density === 'four-blocks',
'rule-knowledge-graph--decision-tree': density === 'decision-tree',
'rule-knowledge-graph--fullscreen': isFullscreen,
}"
>
<div class="rule-knowledge-graph__toolbar">
<a-radio-group v-model:value="density" size="small" button-style="solid">
<a-radio-button value="overview">简要结构</a-radio-button>
<a-radio-button value="full">完整</a-radio-button>
<a-radio-button value="decision-tree-simple">决策树简化</a-radio-button>
<a-radio-button value="decision-tree">决策树</a-radio-button>
<a-radio-button value="full">完整图谱</a-radio-button>
<a-radio-button value="four-blocks">四块分区</a-radio-button>
</a-radio-group>
<span v-if="density !== 'four-blocks'" class="rule-knowledge-graph__hint">
简要仅层级种类模块规则完整含参数任务类型与执行顺序边四块业务运算步骤 + 规则项 + 参数 globalParams 一致
<span v-if="density === 'decision-tree-simple'" class="rule-knowledge-graph__hint">
决策树简化把装备目标阵位航迹四个规则块合并成一棵总树只保留关键参数核心规则和阶段输出
</span>
<span v-else-if="density === 'decision-tree'" class="rule-knowledge-graph__hint">
决策树按装备目标阵位航迹四个规则块展示输入参数运算步骤命中规则与结果产出
</span>
<span v-else-if="density === 'full'" class="rule-knowledge-graph__hint">
完整图谱包含参数任务类型与执行顺序
</span>
<span v-else class="rule-knowledge-graph__hint rule-knowledge-graph__hint--compact">
宫格 · 拖拽画布 / 滚轮缩放
块分区可拖动画布滚轮缩放
</span>
<a-button type="default" size="small" class="rule-knowledge-graph__fullscreen-btn" @click="toggleFullscreen">
<template #icon>
@@ -35,7 +43,9 @@
{{ isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
</div>
<RuleFourBlocksPanel v-if="density === 'four-blocks'" :refresh-key="refreshKey" />
<RuleDecisionTreeSimplePanel v-if="density === 'decision-tree-simple'" :query="query" :refresh-key="refreshKey" />
<RuleDecisionTreePanel v-else-if="density === 'decision-tree'" :query="query" :refresh-key="refreshKey" />
<RuleFourBlocksPanel v-else-if="density === 'four-blocks'" :refresh-key="refreshKey" />
<template v-else>
<div v-if="errorMsg" class="rule-knowledge-graph__banner rule-knowledge-graph__banner--error">
{{ errorMsg }}
@@ -54,10 +64,12 @@ import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { findRuleConfigGraph } from './api';
import RuleDecisionTreeSimplePanel from './RuleDecisionTreeSimplePanel.vue';
import RuleDecisionTreePanel from './RuleDecisionTreePanel.vue';
import RuleFourBlocksPanel from './RuleFourBlocksPanel.vue';
import type { RuleConfigRequest, RuleGraphEdge, RuleGraphNode, RuleGraphPayload } from './types';
import type { RuleConfigRequest, RuleGraphPayload } from './types';
type RuleGraphDensityMode = 'overview' | 'full' | 'four-blocks';
type RuleGraphDensityMode = 'decision-tree-simple' | 'decision-tree' | 'full' | 'four-blocks';
const emit = defineEmits<{
densityChange: [RuleGraphDensityMode],
@@ -73,7 +85,7 @@ const hostRef = ref<HTMLDivElement | null>(null);
const isFullscreen = ref(false);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const density = ref<RuleGraphDensityMode>('overview');
const density = ref<RuleGraphDensityMode>('decision-tree-simple');
const lastPayload = ref<RuleGraphPayload | null>(null);
let graph: Graph | null = null;
@@ -88,56 +100,6 @@ const NODE_COLORS: Record<string, string> = {
taskType: '#269A99',
};
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/** 简要:只保留结构主干,边改为 level → kind → module → rule 便于分层布局 */
const buildOverviewPayload = (payload: RuleGraphPayload): RuleGraphPayload => {
const keepTypes = new Set(['level', 'kind', 'module', 'rule']);
const nodes = payload.nodes.filter((n) => keepTypes.has(n.nodeType));
const nodeIds = new Set(nodes.map((n) => n.id));
const edgeSeen = new Set<string>();
const edges: RuleGraphEdge[] = [];
const addEdge = (source: string, target: string, suffix: string) => {
if (!nodeIds.has(source) || !nodeIds.has(target)) {
return;
}
const key = `${source}|${target}`;
if (edgeSeen.has(key)) {
return;
}
edgeSeen.add(key);
edges.push({
id: `ov:${suffix}:${key}`,
source,
target,
edgeType: 'overview_hierarchy',
label: null,
});
};
for (const r of nodes) {
if (r.nodeType !== 'rule' || !r.payload) {
continue;
}
const levelCode = str(r.payload.levelCode);
const kindCode = str(r.payload.kindCode);
const moduleCode = str(r.payload.moduleCode);
if (!levelCode || !kindCode || !moduleCode) {
continue;
}
const lid = `level:${levelCode}`;
const kid = `kind:${levelCode}:${kindCode}`;
const mid = `module:${moduleCode}`;
addEdge(lid, kid, 'lk');
addEdge(kid, mid, 'km');
addEdge(mid, r.id, 'mr');
}
return { nodes, edges };
};
const toGraphData = (payload: RuleGraphPayload) => ({
nodes: payload.nodes.map((n) => ({
id: n.id,
@@ -213,56 +175,36 @@ const disposeGraph = async () => {
}
};
const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full') => {
const buildGraph = async (payload: RuleGraphPayload) => {
await disposeGraph();
const el = hostRef.value;
if (!el) {
return;
}
const raw = mode === 'overview' ? buildOverviewPayload(payload) : payload;
const data = toGraphData(raw);
if (raw.nodes.length === 0) {
const data = toGraphData(payload);
if (!payload.nodes.length) {
return;
}
const width = Math.max(el.clientWidth, 280);
const height = Math.max(el.clientHeight, 240);
const layout =
mode === 'overview'
? {
type: 'antv-dagre' as const,
rankdir: 'TB',
ranksep: 56,
nodesep: 36,
}
: {
type: 'd3-force' as const,
};
graph = new Graph({
container: el,
width,
height,
data,
layout,
layout: {
type: 'd3-force' as const,
},
node: {
style: {
size: (d: { data?: { nodeType?: string } }) => {
const t = d.data?.nodeType;
if (mode === 'overview') {
if (t === 'rule') return 20;
if (t === 'module') return 18;
return 16;
}
return t === 'param' ? 5 : 11;
},
size: (d: { data?: { nodeType?: string } }) => (d.data?.nodeType === 'param' ? 5 : 11),
fill: (d: { data?: { nodeType?: string } }) => NODE_COLORS[d.data?.nodeType ?? ''] ?? '#8B8B8B',
labelText: (d: { data?: { label?: string }; id: string }) => d.data?.label ?? String(d.id),
labelFill: '#e8f4f8',
labelFontSize: (d: { data?: { nodeType?: string } }) =>
(mode === 'overview' ? (d.data?.nodeType === 'rule' ? 11 : 12) : 9),
labelMaxWidth: mode === 'overview' ? 200 : 100,
labelFontSize: 9,
labelMaxWidth: 100,
labelWordWrap: true,
lineWidth: 1.5,
stroke: '#0d1f2c',
@@ -270,19 +212,11 @@ const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full')
},
edge: {
style: {
stroke: (d: { data?: { edgeType?: string } }) => {
const t = d.data?.edgeType;
if (t === 'overview_hierarchy') {
return 'rgba(120, 170, 200, 0.55)';
}
if (t === 'rule_exec_before') {
return '#faad14';
}
return 'rgba(150, 175, 190, 0.35)';
},
stroke: (d: { data?: { edgeType?: string } }) =>
(d.data?.edgeType === 'rule_exec_before' ? '#faad14' : 'rgba(150, 175, 190, 0.35)'),
lineWidth: (d: { data?: { edgeType?: string } }) =>
(d.data?.edgeType === 'rule_exec_before' ? 2 : 1),
endArrow: mode !== 'overview',
endArrow: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
@@ -302,18 +236,11 @@ const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full')
};
const renderFromCache = async () => {
if (density.value === 'four-blocks') {
return;
}
if (!lastPayload.value) {
return;
}
if (!lastPayload.value.nodes?.length) {
if (density.value !== 'full' || !lastPayload.value || !lastPayload.value.nodes?.length) {
return;
}
await nextTick();
const mode: 'overview' | 'full' = density.value === 'full' ? 'full' : 'overview';
await buildGraph(lastPayload.value, mode);
await buildGraph(lastPayload.value);
};
const load = async () => {
@@ -336,13 +263,12 @@ const load = async () => {
lastPayload.value = null;
return;
}
const payload = r.data;
if (!payload.nodes?.length) {
if (!r.data.nodes?.length) {
emptyHint.value = '当前页无规则数据,图谱为空';
lastPayload.value = null;
return;
}
lastPayload.value = payload;
lastPayload.value = r.data;
await renderFromCache();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
@@ -355,7 +281,7 @@ const load = async () => {
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
if (density.value === 'four-blocks') {
if (density.value !== 'full') {
return;
}
void load();
@@ -365,11 +291,11 @@ watch(
watch(density, async (mode, prev) => {
emit('densityChange', mode);
if (mode === 'four-blocks') {
if (mode !== 'full') {
await disposeGraph();
return;
}
if (prev === 'four-blocks') {
if (prev !== 'full') {
await load();
return;
}
@@ -418,42 +344,10 @@ onBeforeUnmount(() => {
font-size: 11px;
color: #7a8d96;
line-height: 1.35;
max-width: 100%;
}
.rule-knowledge-graph__hint--compact {
font-size: 10px;
color: #6d8290;
}
.rule-knowledge-graph--four-blocks {
min-height: 0;
}
.rule-knowledge-graph--fullscreen {
box-sizing: border-box;
width: 100vw;
height: 100vh;
max-height: 100vh;
padding: 10px 12px;
gap: 10px;
background: #0d1f2c;
}
.rule-knowledge-graph--fullscreen .rule-knowledge-graph__host {
min-height: 0;
flex: 1;
}
.rule-knowledge-graph--fullscreen :deep(.rule-four-blocks) {
flex: 1;
min-height: 0;
}
.rule-knowledge-graph__host {
flex: 1;
min-height: 200px;
position: relative;
letter-spacing: 0.2px;
}
.rule-knowledge-graph__banner {
@@ -470,4 +364,18 @@ onBeforeUnmount(() => {
.rule-knowledge-graph__banner--error {
color: #ff9c9c;
}
.rule-knowledge-graph__host {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid rgba(80, 120, 150, 0.18);
border-radius: 4px;
background: rgba(4, 18, 28, 0.35);
}
.rule-knowledge-graph--fullscreen {
background: #061522;
padding: 12px;
}
</style>

View File

@@ -10,7 +10,7 @@
import { HttpRequestClient, originalAxios } from '@/utils/request';
import type { ApiDataResponse, BasicResponse } from '@/types';
import type { AxiosResponse } from 'axios';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDictItem, RuleFourBlocksPayload, RuleGraphPayload, RuleParamMeta } from './types';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDecisionTreePayload, RuleDictItem, RuleFourBlocksPayload, RuleGraphPayload, RuleParamMeta } from './types';
const req = HttpRequestClient.create<BasicResponse>({
baseURL: '/api',
@@ -52,6 +52,10 @@ export const findRuleFourBlocksGraph = (): Promise<ApiDataResponse<RuleFourBlock
return req.get('/system/rule/config/graph/four-blocks');
};
export const findRuleDecisionTree = (query: Partial<RuleConfigRequest> = {}): Promise<ApiDataResponse<RuleDecisionTreePayload>> => {
return req.get('/system/rule/config/decision-tree', query);
};
export const exportRuleConfig = (query: Partial<RuleConfigRequest> = {}): Promise<AxiosResponse<Blob>> => {
return originalAxios.post('/api/system/rule/config/export', query, {
responseType: 'blob',

View File

@@ -300,7 +300,7 @@ const clampRightPanelWidth = (n: number) => {
return Math.min(dynamicMax, Math.max(RIGHT_PANEL_MIN, Math.round(n)));
};
const onRuleGraphDensityChange = (mode: 'overview' | 'full' | 'four-blocks') => {
const onRuleGraphDensityChange = (mode: 'decision-tree-simple' | 'decision-tree' | 'full' | 'four-blocks') => {
if (mode === 'four-blocks') {
if (graphPanePercentBeforeFourBlocks.value === null) {
graphPanePercentBeforeFourBlocks.value = graphPanePercent.value;

View File

@@ -135,3 +135,26 @@ export interface RuleFourBlocksPayload {
globalParamsPreview: Record<string, unknown>,
blocks: RuleFourBlockCluster[],
}
export interface RuleDecisionNode {
key: string,
title: string,
nodeType: string,
description?: string | null,
valueText?: string | null,
children?: RuleDecisionNode[] | null,
}
export interface RuleDecisionBlock {
blockId: string,
moduleCode: NullableString,
title: NullableString,
droolsRuleName: NullableString,
salience: number | null,
nodes: RuleDecisionNode[],
}
export interface RuleDecisionTreePayload {
globalParamsPreview: Record<string, unknown>,
blocks: RuleDecisionBlock[],
}