Files
auto-solution/modeler/src/views/decision/rule-config/RuleKnowledgeGraph.vue
2026-05-07 16:56:16 +08:00

382 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
- This file is part of the kernelstudio package.
-
- (c) 2014-2026 zlin <admin@kernelstudio.com>
-
- For the full copyright and license information, please view the LICENSE file
- that was distributed with this source code.
-->
<template>
<div
ref="graphShellRef"
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="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 === '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>
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</template>
{{ isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
</div>
<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 }}
</div>
<div v-else-if="emptyHint" class="rule-knowledge-graph__banner">
{{ emptyHint }}
</div>
<div v-show="!errorMsg && !emptyHint" ref="hostRef" class="rule-knowledge-graph__host" />
</template>
</div>
</template>
<script setup lang="ts">
import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons-vue';
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, RuleGraphPayload } from './types';
type RuleGraphDensityMode = 'decision-tree-simple' | 'decision-tree' | 'full' | 'four-blocks';
const emit = defineEmits<{
densityChange: [RuleGraphDensityMode],
}>();
const props = defineProps<{
query: RuleConfigRequest,
refreshKey: number,
}>();
const graphShellRef = ref<HTMLElement | null>(null);
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>('decision-tree-simple');
const lastPayload = ref<RuleGraphPayload | null>(null);
let graph: Graph | null = null;
let resizeObserver: ResizeObserver | null = null;
const NODE_COLORS: Record<string, string> = {
level: '#5B8FF9',
kind: '#61DDAA',
module: '#65789B',
rule: '#F6903D',
param: '#9270CA',
taskType: '#269A99',
};
const toGraphData = (payload: RuleGraphPayload) => ({
nodes: payload.nodes.map((n) => ({
id: n.id,
data: {
label: n.label,
nodeType: n.nodeType,
payload: n.payload ?? {},
},
})),
edges: payload.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
data: {
edgeType: e.edgeType ?? '',
label: e.label ?? '',
},
})),
});
const syncFullscreenState = () => {
const el = graphShellRef.value;
isFullscreen.value = Boolean(el && document.fullscreenElement === el);
};
const refitMainGraphAfterLayout = () => {
if (!graph || !hostRef.value) {
return;
}
const w = Math.max(hostRef.value.clientWidth, 280);
const h = Math.max(hostRef.value.clientHeight, 240);
graph.setSize(w, h);
void graph.fitView();
};
const onFullscreenChange = () => {
syncFullscreenState();
void nextTick(() => {
refitMainGraphAfterLayout();
window.dispatchEvent(new Event('resize'));
window.setTimeout(() => {
refitMainGraphAfterLayout();
}, 120);
});
};
const toggleFullscreen = async () => {
const el = graphShellRef.value;
if (!el) {
return;
}
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch {
message.warning('无法切换全屏,请检查浏览器权限或使用 Chrome / Edge');
}
};
const disposeGraph = async () => {
resizeObserver?.disconnect();
resizeObserver = null;
if (graph) {
try {
graph.destroy();
} catch {
/* ignore */
}
graph = null;
}
};
const buildGraph = async (payload: RuleGraphPayload) => {
await disposeGraph();
const el = hostRef.value;
if (!el) {
return;
}
const data = toGraphData(payload);
if (!payload.nodes.length) {
return;
}
const width = Math.max(el.clientWidth, 280);
const height = Math.max(el.clientHeight, 240);
graph = new Graph({
container: el,
width,
height,
data,
layout: {
type: 'd3-force' as const,
},
node: {
style: {
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: 9,
labelMaxWidth: 100,
labelWordWrap: true,
lineWidth: 1.5,
stroke: '#0d1f2c',
},
},
edge: {
style: {
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: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
});
await graph.render();
await graph.fitView();
resizeObserver = new ResizeObserver(() => {
if (!graph || !hostRef.value) {
return;
}
const w = Math.max(hostRef.value.clientWidth, 280);
const h = Math.max(hostRef.value.clientHeight, 240);
graph.setSize(w, h);
void graph.fitView();
});
resizeObserver.observe(el);
};
const renderFromCache = async () => {
if (density.value !== 'full' || !lastPayload.value || !lastPayload.value.nodes?.length) {
return;
}
await nextTick();
await buildGraph(lastPayload.value);
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
await disposeGraph();
try {
const r = await findRuleConfigGraph({
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 ?? '加载知识图谱失败';
lastPayload.value = null;
return;
}
if (!r.data.nodes?.length) {
emptyHint.value = '当前页无规则数据,图谱为空';
lastPayload.value = null;
return;
}
lastPayload.value = r.data;
await renderFromCache();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `图谱请求失败:${msg}`;
lastPayload.value = null;
message.error(errorMsg.value);
}
};
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
if (density.value !== 'full') {
return;
}
void load();
},
{ immediate: true },
);
watch(density, async (mode, prev) => {
emit('densityChange', mode);
if (mode !== 'full') {
await disposeGraph();
return;
}
if (prev !== 'full') {
await load();
return;
}
await renderFromCache();
});
onMounted(() => {
syncFullscreenState();
document.addEventListener('fullscreenchange', onFullscreenChange);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
void disposeGraph();
});
</script>
<style scoped lang="less">
.rule-knowledge-graph {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.rule-knowledge-graph__toolbar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 0 2px 4px;
}
.rule-knowledge-graph__fullscreen-btn {
margin-left: auto;
flex-shrink: 0;
color: #b8ccd6;
border-color: rgba(120, 170, 200, 0.45);
background: rgba(10, 28, 40, 0.65);
}
.rule-knowledge-graph__hint {
font-size: 11px;
color: #7a8d96;
line-height: 1.35;
}
.rule-knowledge-graph__hint--compact {
letter-spacing: 0.2px;
}
.rule-knowledge-graph__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.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>