382 lines
11 KiB
Vue
382 lines
11 KiB
Vue
<!--
|
||
- 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>
|